diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cca2e..4610b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- feat: create slice with immer #24 + ## [0.2.0] - 2024-05-04 ### Added diff --git a/package.json b/package.json index e71fadf..ec83768 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,16 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" } + }, + "./immer": { + "require": { + "types": "./dist/cjs/immer/index.d.ts", + "default": "./dist/cjs/immer/index.js" + }, + "default": { + "types": "./dist/immer/index.d.ts", + "default": "./dist/immer/index.js" + } } }, "sideEffects": false, @@ -81,7 +91,13 @@ "zustand-slices": "link:." }, "peerDependencies": { + "immer": ">=9.0.6", "react": ">=18.0.0", "zustand": ">=4.0.0" + }, + "peerDependenciesMeta": { + "immer": { + "optional": true + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8ea348..e9769c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +dependencies: + immer: + specifier: '>=9.0.6' + version: 10.1.1 + devDependencies: '@testing-library/jest-dom': specifier: 6.4.2 @@ -76,7 +81,7 @@ devDependencies: version: 1.5.0(@types/node@20.12.8)(happy-dom@14.7.1) zustand: specifier: ^4.5.2 - version: 4.5.2(@types/react@18.3.1)(react@19.0.0-beta-73bcdfbae5-20240502) + version: 4.5.2(@types/react@18.3.1)(immer@10.1.1)(react@19.0.0-beta-73bcdfbae5-20240502) zustand-slices: specifier: link:. version: 'link:' @@ -2003,6 +2008,9 @@ packages: engines: {node: '>= 4'} dev: true + /immer@10.1.1: + resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -3369,7 +3377,7 @@ packages: engines: {node: '>=12.20'} dev: true - /zustand@4.5.2(@types/react@18.3.1)(react@19.0.0-beta-73bcdfbae5-20240502): + /zustand@4.5.2(@types/react@18.3.1)(immer@10.1.1)(react@19.0.0-beta-73bcdfbae5-20240502): resolution: {integrity: sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==} engines: {node: '>=12.7.0'} peerDependencies: @@ -3385,6 +3393,7 @@ packages: optional: true dependencies: '@types/react': 18.3.1 + immer: 10.1.1 react: 19.0.0-beta-73bcdfbae5-20240502 use-sync-external-store: 1.2.0(react@19.0.0-beta-73bcdfbae5-20240502) dev: true diff --git a/src/immer/create-slice-with-immer.ts b/src/immer/create-slice-with-immer.ts new file mode 100644 index 0000000..4d6a4c8 --- /dev/null +++ b/src/immer/create-slice-with-immer.ts @@ -0,0 +1,46 @@ +import { produce } from 'immer'; +import type { Draft } from 'immer'; + +// Utility type to infer the argument types of the actions +type InferArgs = T extends (...args: infer A) => unknown ? A : never; + +type SliceActions = { + [actionName: string]: ( + ...args: never[] + ) => (draft: Draft) => Draft | void; +}; + +export type SliceConfig< + Name extends string, + Value, + Actions extends SliceActions, +> = { + name: Name; + value: Value; + actions: Actions; +}; + +type ImmerActions> = { + [K in keyof Actions]: ( + ...args: InferArgs + ) => (prev: Value) => Value; +}; + +export function createSliceWithImmer< + Name extends string, + Value, + Actions extends SliceActions, +>(config: SliceConfig) { + const immerActions = Object.fromEntries( + Object.entries(config.actions).map(([actionKey, action]) => [ + actionKey, + (...args: InferArgs) => + (prev: Value) => + produce(prev, (draft) => action(...args)(draft)), + ]), + ) as unknown as ImmerActions; + return { + ...config, + actions: immerActions, + }; +} diff --git a/src/immer/index.ts b/src/immer/index.ts new file mode 100644 index 0000000..568691a --- /dev/null +++ b/src/immer/index.ts @@ -0,0 +1 @@ +export { createSliceWithImmer } from './create-slice-with-immer.js'; diff --git a/tests/05_createSliceWithImmer.spec.tsx b/tests/05_createSliceWithImmer.spec.tsx new file mode 100644 index 0000000..73a1f5d --- /dev/null +++ b/tests/05_createSliceWithImmer.spec.tsx @@ -0,0 +1,49 @@ +import { expect, test } from 'vitest'; +import { createSliceWithImmer } from 'zustand-slices/immer'; + +test('should export functions', () => { + expect(createSliceWithImmer).toBeDefined(); +}); + +test('createSliceWithImmer', () => { + const immerSlice = createSliceWithImmer({ + name: 'counter', + value: { + count: 0, + text: 'First', + }, + actions: { + increment: () => (prev) => { + prev.count += 1; + }, + setText: (payload: { newText: string }) => (prev) => { + prev.text = payload.newText; + }, + reset: () => () => { + return { + count: 0, + text: 'First', + }; + }, + }, + }); + const result = createSliceWithImmer(immerSlice); + + // should not be equal as createSliceWithImmer should wrap actions in `produce` + expect(result.actions.increment).not.toBe(immerSlice.actions.increment); + + expect(result.name).toEqual(immerSlice.name); + expect(result.value).toEqual(immerSlice.value); + + expect(typeof result.actions.increment).toBe('function'); + + const incrementedState = result.actions.increment()(result.value); + const newTextState = result.actions.setText({ newText: 'Second' })( + result.value, + ); + const resetState = result.actions.reset()(result.value); + + expect(incrementedState.count).toBe(1); + expect(newTextState.text).toBe('Second'); + expect(resetState).toEqual({ count: 0, text: 'First' }); +}); diff --git a/tsconfig.json b/tsconfig.json index 65f619a..f9fb1a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,8 @@ "jsx": "react-jsx", "baseUrl": ".", "paths": { - "zustand-slices": ["./src/index.js"] + "zustand-slices": ["./src/index.js"], + "zustand-slices/immer": ["./src/immer/index.js"] } }, "exclude": ["dist", "examples"]