diff --git a/.changeset/shy-bats-drop.md b/.changeset/shy-bats-drop.md new file mode 100644 index 0000000..09b5118 --- /dev/null +++ b/.changeset/shy-bats-drop.md @@ -0,0 +1,70 @@ +--- +'zustand-x': major +--- + +- Added support for Zustand 4.5.0+. +- `mutative` support. Pass `mutative: true` in the options. + +## Migration Instructions + +Update the Store Initialization: + +1. Replace the old method of initializing the store with the new API. + + ```tsx + const store = createStore( + () => ({ + name: 'zustandX', + stars: 0, + }), + { + name: 'repo', + immer: true, + } + ); + ``` + + or + + ```tsx + const store = createStore({ + name: 'zustandX', + stars: 0, + }, + { + name: 'repo', + immer: true, + } + ); + ``` + +2. Ensure to pass the configuration object with name and other options as needed. +3. If your application relies on immer, enable it by passing immer: true in the configuration object. + + ```tsx + const store = createStore( + () => ({ + name: 'zustandX', + stars: 0, + }), + { + name: 'repo', + immer: true, + } + ); + ``` + +4. With the new version, integrating middlewares has also changed. Here's how to upgrade your middleware usage: + + ```tsx + const store = createStore( + middlewareWrapHere(() => ({ + name: 'zustandX', + stars: 0, + })), + { + name: 'repo', + immer: true, + } + ); + ``` diff --git a/README.md b/README.md index 609d158..90c3b61 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -> [!NOTE] -> `@udecode/zustood` has been renamed to `zustand-x`. +> [!NOTE] > `@udecode/zustood` has been renamed to `zustand-x`. > Using Jotai? See [JotaiX](https://github.com/udecode/jotai-x). # ZustandX @@ -21,12 +20,9 @@ code. which solves these challenges, so you can focus on your app. ```bash -yarn add zustand@4.4.7 zustand-x +yarn add zustand@latest zustand-x ``` -> [!IMPORTANT] -> `zustand` 4.5.0+ is not yet supported. See https://github.com/udecode/zustand-x/issues/79. - Visit [zustand-x.udecode.dev](https://zustand-x.udecode.dev) for the API. @@ -36,23 +32,28 @@ API. - Modular state management: - Derived selectors - Derived actions -- `immer`, `devtools` and `persist` middlewares +- `immer`, `devtools` , `mutative` and `persist` middlewares - Full typescript support - `react-tracked` support ## Create a store ```ts -import { createStore } from 'zustand-x' - -const repoStore = createStore('repo')({ - name: 'zustandX', - stars: 0, - owner: { - name: 'someone', - email: 'someone@xxx.com', +import { createStore } from 'zustand-x'; + +const repoStore = createStore( + { + name: 'zustandX', + stars: 0, + owner: { + name: 'someone', + email: 'someone@xxx.com', + }, }, -}) + { + name: 'repo', + } +); ``` - the parameter of the first function is the name of the store, this is @@ -66,10 +67,10 @@ Note that the zustand store is accessible through: ```ts // hook store -repoStore.useStore +repoStore.useStore; // vanilla store -repoStore.store +repoStore.store; ``` ## Selectors @@ -80,8 +81,8 @@ Use the hooks in React components, no providers needed. Select your state and the component will re-render on changes. Use the `use` method: ```ts -repoStore.use.name() -repoStore.use.stars() +repoStore.use.name(); +repoStore.use.stars(); ``` We recommend using the global hooks (see below) to support ESLint hook @@ -95,7 +96,7 @@ Use the tracked hooks in React components, no providers needed. Select your state and the component will trigger re-renders only if the **accessed property** is changed. Use the `useTracked` method: ```ts -repoStore.useTracked.owner() +repoStore.useTracked.owner(); ``` ### Getters @@ -104,14 +105,14 @@ Don't overuse hooks. If you don't need to subscribe to the state, use instead the `get` method: ```ts -repoStore.get.name() -repoStore.get.stars() +repoStore.get.name(); +repoStore.get.stars(); ``` You can also get the whole state: ```ts -repoStore.get.state() +repoStore.get.state(); ``` ### Extend selectors @@ -121,11 +122,16 @@ selectors) for reusability. ZustandX supports extending selectors with full typescript support: ```ts -const repoStore = createStore('repo')({ - name: 'zustandX', - stars: 0, - middlewares: ['immer', 'devtools', 'persist'] -}) +const repoStore = createStore( + { + name: 'zustandX', + stars: 0, + middlewares: ['immer', 'devtools', 'persist'], + }, + { + name: 'repo', + } +) .extendSelectors((state, get, api) => ({ validName: () => get.name().trim(), // other selectors @@ -134,8 +140,8 @@ const repoStore = createStore('repo')({ // get.validName is accessible title: (prefix: string) => `${prefix + get.validName()} with ${get.stars()} stars`, - })) - // extend again... + })); +// extend again... ``` ## Actions @@ -143,8 +149,8 @@ const repoStore = createStore('repo')({ Update your store from anywhere by using the `set` method: ```ts -repoStore.set.name('new name') -repoStore.set.stars(repoStore.get.stars + 1) +repoStore.set.name('new name'); +repoStore.set.stars(repoStore.get.stars + 1); ``` ### Extend actions @@ -155,6 +161,7 @@ You can update the whole state from your app: store.set.state((draft) => { draft.name = 'test'; draft.stars = 1; + return draft; }); ``` @@ -162,10 +169,15 @@ However, you generally want to create derived actions for reusability. ZustandX supports extending actions with full typescript support: ```ts -const repoStore = createStore('repo')({ - name: 'zustandX', - stars: 0, -}) +const repoStore = createStore( + { + name: 'zustandX', + stars: 0, + }, + { + name: 'repo', + } +) .extendActions((set, get, api) => ({ validName: (name: string) => { set.name(name.trim()); @@ -178,8 +190,8 @@ const repoStore = createStore('repo')({ set.validName(name); set.stars(0); }, - })) - // extend again... + })); +// extend again... ``` ## Global store @@ -218,14 +230,8 @@ export const actions = mapValuesKey('set', rootStore); ### Global hook selectors ```ts -import shallow from 'zustand/shallow' - -useStore().repo.name() -useStore().modal.isOpen() - -// prevent unnecessary re-renders -// more see: https://docs.pmnd.rs/zustand/recipes#selecting-multiple-state-slices -useStore().repo.middlewares(shallow) +useStore().repo.name(); +useStore().modal.isOpen(); ``` ### Global tracked hook selectors @@ -258,8 +264,8 @@ By using `useStore() or useTrackStore()`, ESLint will correctly lint hook errors ### Global getter selectors ```ts -store.repo.name() -store.modal.isOpen() +store.repo.name(); +store.modal.isOpen(); ``` These can be used anywhere. @@ -267,8 +273,8 @@ These can be used anywhere. ### Global actions ```ts -actions.repo.stars(store.repo.stars + 1) -actions.modal.open() +actions.repo.stars(store.repo.stars + 1); +actions.modal.open(); ``` These can be used anywhere. @@ -278,10 +284,11 @@ These can be used anywhere. The second parameter of `createStore` is for options: ```ts -export interface CreateStoreOptions { - middlewares?: any[]; +export interface CreateStoreOptions { + name: string; devtools?: DevtoolsOptions; immer?: ImmerOptions; + mutative?: MutativeOptions; persist?: PersistOptions; } ``` @@ -289,13 +296,35 @@ export interface CreateStoreOptions { ### Middlewares ZustandX is using these middlewares: -- `immer`: required. Autofreeze can be enabled using - `immer.enabledAutoFreeze` option. -- `devtools`: enabled if `devtools.enabled` option is `true`. + +- `immer`: enabled if `immer.enabled` option is `true`. `immer` implements from [zustand](https://github.com/pmndrs/zustand?tab=readme-ov-file#immer-middleware). +- `devtools`: enabled if `devtools.enabled` option is `true`. `devtools` implements `DevtoolsOptions` interface from [zustand](https://github.com/pmndrs/zustand?tab=readme-ov-file#redux-devtools). +- `mutative`: enabled if `mutative.enabled` option is `true`. - `persist`: enabled if `persist.enabled` option is `true`. `persist` implements `PersistOptions` interface from [zustand](https://github.com/pmndrs/zustand#persist-middleware) -- custom middlewares can be added using `middlewares` option +- custom middlewares can be added by wrapping `state initiator`. + +```ts +import { createStore } from 'zustand-x'; +import { combine } from 'zustand/middleware'; + +const store = createStore<{ name: string; stars?: number }>( + combine( + () => ({ + name: 'zustandX', + }), + () => ({ stars: 0 }) + ), + { + name: 'repo', + //enables persist middleware + persist: { + enabled: true, + }, + } +); +``` ## Contributing and project organization diff --git a/package.json b/package.json index 2cc2e58..1f321a4 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,6 @@ "prettier": "^3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-is": "18.2.0", "react-test-renderer": "18.2.0", "rimraf": "^5.0.5", "ts-jest": "^29.1.1", @@ -102,13 +101,15 @@ "turbo": "^1.11.0", "turbowatch": "2.29.4", "typedoc": "^0.25.4", - "typescript": "5.3.3", - "zustand": "^4.4.7" + "typescript": "5.3.3" }, "packageManager": "yarn@4.0.2", "engines": { "node": ">=18.12.0", "yarn": ">=1.22.0", "npm": "please-use-yarn" + }, + "dependencies": { + "zustand": "5.0.2" } } diff --git a/packages/zustand-x/README.md b/packages/zustand-x/README.md index eee3aec..90c3b61 100644 --- a/packages/zustand-x/README.md +++ b/packages/zustand-x/README.md @@ -1,5 +1,5 @@ -> [!NOTE] -> `@udecode/zustood` has been renamed to `zustand-x`. +> [!NOTE] > `@udecode/zustood` has been renamed to `zustand-x`. +> Using Jotai? See [JotaiX](https://github.com/udecode/jotai-x). # ZustandX @@ -12,38 +12,48 @@ and [context loss](https://github.com/facebook/react/issues/13332) between mixed renderers. It may be the one state-manager in the React space that gets all of these right. -As zustand is un-opinionated by design, it's challenging to find out the +As `zustand` is un-opinionated by design, it's challenging to find out the best patterns to use when creating stores, often leading to boilerplate code. -ZustandX, built on top of zustand, is providing a powerful store factory +`zustand-x`, built on top of `zustand`, is providing a powerful store factory which solves these challenges, so you can focus on your app. ```bash -yarn add zustand zustand-x +yarn add zustand@latest zustand-x ``` Visit [zustand-x.udecode.dev](https://zustand-x.udecode.dev) for the API. -### Why zustand-x over zustand? +### Why `zustand-x` in addition to `zustand`? - Much less boilerplate - Modular state management: - Derived selectors - Derived actions -- `immer`, `devtools` and `persist` middlewares +- `immer`, `devtools` , `mutative` and `persist` middlewares - Full typescript support +- `react-tracked` support ## Create a store ```ts -import { createStore } from 'zustand-x' - -const repoStore = createStore('repo')({ - name: 'zustandX', - stars: 0, -}) +import { createStore } from 'zustand-x'; + +const repoStore = createStore( + { + name: 'zustandX', + stars: 0, + owner: { + name: 'someone', + email: 'someone@xxx.com', + }, + }, + { + name: 'repo', + } +); ``` - the parameter of the first function is the name of the store, this is @@ -57,10 +67,10 @@ Note that the zustand store is accessible through: ```ts // hook store -repoStore.useStore +repoStore.useStore; // vanilla store -repoStore.store +repoStore.store; ``` ## Selectors @@ -71,27 +81,38 @@ Use the hooks in React components, no providers needed. Select your state and the component will re-render on changes. Use the `use` method: ```ts -repoStore.use.name() -repoStore.use.stars() +repoStore.use.name(); +repoStore.use.stars(); ``` We recommend using the global hooks (see below) to support ESLint hook linting. +### Tracked Hooks + +> Big thanks for [react-tracked](https://github.com/dai-shi/react-tracked) + +Use the tracked hooks in React components, no providers needed. Select your +state and the component will trigger re-renders only if the **accessed property** is changed. Use the `useTracked` method: + +```ts +repoStore.useTracked.owner(); +``` + ### Getters Don't overuse hooks. If you don't need to subscribe to the state, use instead the `get` method: ```ts -repoStore.get.name() -repoStore.get.stars() +repoStore.get.name(); +repoStore.get.stars(); ``` You can also get the whole state: ```ts -repoStore.get.state() +repoStore.get.state(); ``` ### Extend selectors @@ -101,20 +122,26 @@ selectors) for reusability. ZustandX supports extending selectors with full typescript support: ```ts -const repoStore = createStore('repo')({ - name: 'zustandX', - stars: 0, -}) - .extendSelectors((set, get, api) => ({ +const repoStore = createStore( + { + name: 'zustandX', + stars: 0, + middlewares: ['immer', 'devtools', 'persist'], + }, + { + name: 'repo', + } +) + .extendSelectors((state, get, api) => ({ validName: () => get.name().trim(), // other selectors })) - .extendSelectors((set, get, api) => ({ + .extendSelectors((state, get, api) => ({ // get.validName is accessible title: (prefix: string) => `${prefix + get.validName()} with ${get.stars()} stars`, - })) - // extend again... + })); +// extend again... ``` ## Actions @@ -122,8 +149,8 @@ const repoStore = createStore('repo')({ Update your store from anywhere by using the `set` method: ```ts -repoStore.set.name('new name') -repoStore.set.stars(repoStore.get.stars + 1) +repoStore.set.name('new name'); +repoStore.set.stars(repoStore.get.stars + 1); ``` ### Extend actions @@ -134,6 +161,7 @@ You can update the whole state from your app: store.set.state((draft) => { draft.name = 'test'; draft.stars = 1; + return draft; }); ``` @@ -141,10 +169,15 @@ However, you generally want to create derived actions for reusability. ZustandX supports extending actions with full typescript support: ```ts -const repoStore = createStore('repo')({ - name: 'zustandX', - stars: 0, -}) +const repoStore = createStore( + { + name: 'zustandX', + stars: 0, + }, + { + name: 'repo', + } +) .extendActions((set, get, api) => ({ validName: (name: string) => { set.name(name.trim()); @@ -157,8 +190,8 @@ const repoStore = createStore('repo')({ set.validName(name); set.stars(0); }, - })) - // extend again... + })); +// extend again... ``` ## Global store @@ -184,6 +217,9 @@ export const rootStore = { // Global hook selectors export const useStore = () => mapValuesKey('use', rootStore); +// Global tracked hook selectors +export const useTrackedStore = () => mapValuesKey('useTracked', rootStore); + // Global getter selectors export const store = mapValuesKey('get', rootStore); @@ -194,17 +230,42 @@ export const actions = mapValuesKey('set', rootStore); ### Global hook selectors ```ts -useStore().repo.name() -useStore().modal.isOpen() +useStore().repo.name(); +useStore().modal.isOpen(); ``` -By using `useStore()`, ESLint will correctly lint hook errors. +### Global tracked hook selectors + +```tsx +// with useTrackStore UserEmail Component will only re-render when accessed property owner.email changed +const UserEmail = () => { + const owner = useTrackedStore().repo.owner() + return ( +
+ User Email: {owner.email} +
+ ); +}; + +// with useStore UserEmail Component re-render when owner changed, but you can pass equalityFn to avoid it. +const UserEmail = () => { + const owner = useStore().repo.owner() + // const owner = useStore().repo.owner((prev, next) => prev.email === next.email) + return ( +
+ User Email: {owner.email} +
+ ); +}; +``` + +By using `useStore() or useTrackStore()`, ESLint will correctly lint hook errors. ### Global getter selectors ```ts -store.repo.name() -store.modal.isOpen() +store.repo.name(); +store.modal.isOpen(); ``` These can be used anywhere. @@ -212,8 +273,8 @@ These can be used anywhere. ### Global actions ```ts -actions.repo.stars(store.repo.stars + 1) -actions.modal.open() +actions.repo.stars(store.repo.stars + 1); +actions.modal.open(); ``` These can be used anywhere. @@ -223,10 +284,11 @@ These can be used anywhere. The second parameter of `createStore` is for options: ```ts -export interface CreateStoreOptions { - middlewares?: any[]; +export interface CreateStoreOptions { + name: string; devtools?: DevtoolsOptions; immer?: ImmerOptions; + mutative?: MutativeOptions; persist?: PersistOptions; } ``` @@ -234,13 +296,35 @@ export interface CreateStoreOptions { ### Middlewares ZustandX is using these middlewares: -- `immer`: required. Autofreeze can be enabled using - `immer.enabledAutoFreeze` option. -- `devtools`: enabled if `devtools.enabled` option is `true`. + +- `immer`: enabled if `immer.enabled` option is `true`. `immer` implements from [zustand](https://github.com/pmndrs/zustand?tab=readme-ov-file#immer-middleware). +- `devtools`: enabled if `devtools.enabled` option is `true`. `devtools` implements `DevtoolsOptions` interface from [zustand](https://github.com/pmndrs/zustand?tab=readme-ov-file#redux-devtools). +- `mutative`: enabled if `mutative.enabled` option is `true`. - `persist`: enabled if `persist.enabled` option is `true`. `persist` implements `PersistOptions` interface from [zustand](https://github.com/pmndrs/zustand#persist-middleware) -- custom middlewares can be added using `middlewares` option +- custom middlewares can be added by wrapping `state initiator`. + +```ts +import { createStore } from 'zustand-x'; +import { combine } from 'zustand/middleware'; + +const store = createStore<{ name: string; stars?: number }>( + combine( + () => ({ + name: 'zustandX', + }), + () => ({ stars: 0 }) + ), + { + name: 'repo', + //enables persist middleware + persist: { + enabled: true, + }, + } +); +``` ## Contributing and project organization @@ -260,6 +344,12 @@ your feedback** here. Read our [contributing guide](https://github.com/udecode/zustand-x/blob/main/CONTRIBUTING.md) to get started. +

+ + Deploys by Netlify + +

+ ## License [MIT](https://github.com/udecode/zustand-x/blob/main/LICENSE) diff --git a/packages/zustand-x/package.json b/packages/zustand-x/package.json index b81118d..65417e3 100644 --- a/packages/zustand-x/package.json +++ b/packages/zustand-x/package.json @@ -1,6 +1,6 @@ { "name": "zustand-x", - "version": "3.0.4", + "version": "4.0.0", "description": "Zustand store factory for a best-in-class developer experience.", "license": "MIT", "homepage": "https://zustand-x.udecode.dev", @@ -40,10 +40,12 @@ "dependencies": { "immer": "^10.0.3", "lodash.mapvalues": "^4.6.0", - "react-tracked": "^1.7.11" + "mutative": "1.1.0", + "react-tracked": "^1.7.11", + "use-sync-external-store": "1.4.0" }, "peerDependencies": { - "zustand": ">=4.3.9" + "zustand": ">=5.0.2" }, "keywords": [ "zustand" diff --git a/packages/zustand-x/src/createStore.ts b/packages/zustand-x/src/createStore.ts index 111ea1f..10465c5 100644 --- a/packages/zustand-x/src/createStore.ts +++ b/packages/zustand-x/src/createStore.ts @@ -1,128 +1,139 @@ -import { enableMapSet, setAutoFreeze } from 'immer'; import { createTrackedSelector } from 'react-tracked'; -import { - devtools as devtoolsMiddleware, - persist as persistMiddleware, -} from 'zustand/middleware'; -import { useStoreWithEqualityFn } from 'zustand/traditional'; -import { createStore as createVanillaStore } from 'zustand/vanilla'; +import { createWithEqualityFn as createStoreZustand } from 'zustand/traditional'; -import { immerMiddleware } from './middlewares/immer.middleware'; import { - ImmerStoreApi, - MergeState, - SetImmerState, - State, - StateActions, - StateGetters, - StoreApi, - UseImmerStore, -} from './types'; -import { CreateStoreOptions } from './types/CreateStoreOptions'; + devToolsMiddleware, + immerMiddleware, + persistMiddleware, +} from './middlewares'; +import { mutativeMiddleware } from './middlewares/mutative'; +import { DefaultMutators, TBaseStoreOptions, TState } from './types'; +import { TMiddleware } from './types/middleware'; import { generateStateActions } from './utils/generateStateActions'; import { generateStateGetSelectors } from './utils/generateStateGetSelectors'; import { generateStateHookSelectors } from './utils/generateStateHookSelectors'; import { generateStateTrackedHooksSelectors } from './utils/generateStateTrackedHooksSelectors'; -import { pipe } from './utils/pipe'; +import { getOptions } from './utils/helpers'; import { storeFactory } from './utils/storeFactory'; -import type { StateCreator } from 'zustand'; - -export const createStore = - (name: TName) => - ( - initialState: T, - options: CreateStoreOptions = {} - ): StoreApi> => { - const { - middlewares: _middlewares = [], - devtools, - persist, - immer, - } = options; - - setAutoFreeze(immer?.enabledAutoFreeze ?? false); - if (immer?.enableMapSet) { - enableMapSet(); - } - - const middlewares: any[] = [immerMiddleware, ..._middlewares]; - - if (persist?.enabled) { - const opts = { - ...persist, - name: persist.name ?? name, - }; - - middlewares.push((config: any) => persistMiddleware(config, opts)); - } - - if (devtools?.enabled) { - middlewares.push((config: any) => - devtoolsMiddleware(config, { ...devtools, name }) - ); - } - - middlewares.push(createVanillaStore); - - // @ts-ignore - const pipeMiddlewares = (createState: StateCreator>) => - pipe(createState as any, ...middlewares) as ImmerStoreApi; - - const store = pipeMiddlewares(() => initialState); - const useStore = ((selector, equalityFn) => - useStoreWithEqualityFn( - store as any, - selector as any, - equalityFn as any - )) as UseImmerStore; - - const stateActions = generateStateActions(store, name); - - const mergeState: MergeState = (state, actionName) => { - store.setState( - (draft) => { - Object.assign(draft as any, state); - }, - actionName || `@@${name}/mergeState` - ); - }; - - const setState: SetImmerState = (fn, actionName) => { - store.setState(fn, actionName || `@@${name}/setState`); - }; - - const hookSelectors = generateStateHookSelectors(useStore, store); - const getterSelectors = generateStateGetSelectors(store); - - const useTrackedStore = createTrackedSelector(useStore); - const trackedHooksSelectors = generateStateTrackedHooksSelectors( - useTrackedStore, - store +import type { StateCreator, StoreMutatorIdentifier } from 'zustand'; + +/** + * Creates zustand store with additional selectors and actions. + * + * @param {StateType | StateCreator} initializer - A function or object that initializes the state. + * @param {TBaseStoreOptions} options - store create options. + */ + +export const createStore = < + StateType extends TState, + Mps extends [StoreMutatorIdentifier, unknown][] = [], + Mcs extends [StoreMutatorIdentifier, unknown][] = [], + CreateStoreOptions extends + TBaseStoreOptions = TBaseStoreOptions, +>( + initializer: StateType | StateCreator, + options: CreateStoreOptions +) => { + type Mutators = [...DefaultMutators, ...Mcs]; + const { + name, + devtools: devtoolsOptions, + immer: immerOptions, + mutative: mutativeOptions, + persist: persistOptions, + isMutativeState, + } = options; + + //current middlewares order devTools(persist(immer(initiator))) + const middlewares: TMiddleware[] = []; + + //enable devtools + const _devtoolsOptionsInternal = getOptions(devtoolsOptions); + if (_devtoolsOptionsInternal.enabled) { + middlewares.push((config) => + devToolsMiddleware(config, { + ..._devtoolsOptionsInternal, + name: _devtoolsOptionsInternal?.name ?? name, + }) + ); + } + + //enable persist + const _persistOptionsInternal = getOptions(persistOptions); + if (_persistOptionsInternal.enabled) { + middlewares.push((config) => + persistMiddleware(config, { + ..._persistOptionsInternal, + name: _persistOptionsInternal.name ?? name, + }) + ); + } + + //enable immer + const _immerOptionsInternal = getOptions(immerOptions); + if (_immerOptionsInternal.enabled) { + middlewares.push((config) => + immerMiddleware(config, _immerOptionsInternal) ); + } - const api = { - get: { - state: store.getState, - ...getterSelectors, - } as StateGetters, - name, - set: { - state: setState, - mergeState, - ...stateActions, - } as StateActions, - store, - use: hookSelectors, - useTracked: trackedHooksSelectors, - useStore, - useTrackedStore, - extendSelectors: () => api as any, - extendActions: () => api as any, - }; - - return storeFactory(api) as StoreApi>; + //enable mutative + const _mutativeOptionsInternal = getOptions(mutativeOptions); + if (_mutativeOptionsInternal.enabled) { + middlewares.push((config) => + mutativeMiddleware(config, _mutativeOptionsInternal) + ); + } + + const stateMutators = middlewares + .reverse() + .reduce( + (y, fn) => fn(y), + (typeof initializer === 'function' + ? initializer + : () => initializer) as StateCreator + ) as StateCreator; + + const store = createStoreZustand(stateMutators); + + const getterSelectors = generateStateGetSelectors(store); + + const stateActions = generateStateActions( + store, + name, + isMutativeState || + _immerOptionsInternal.enabled || + _mutativeOptionsInternal.enabled + ); + + const hookSelectors = generateStateHookSelectors(store); + + const useTrackedStore = createTrackedSelector(store); + const trackedHooksSelectors = generateStateTrackedHooksSelectors( + useTrackedStore, + store + ); + + const apiInternal = { + get: { + state: store.getState, + ...getterSelectors, + }, + name, + set: { + state: store.setState, + ...stateActions, + }, + store, + useStore: store, + use: hookSelectors, + useTracked: trackedHooksSelectors, + useTrackedStore, }; + return storeFactory(apiInternal); +}; + // Alias {@link createStore} export const createZustandStore = createStore; diff --git a/packages/zustand-x/src/index.ts b/packages/zustand-x/src/index.ts index e484c0a..f83864c 100644 --- a/packages/zustand-x/src/index.ts +++ b/packages/zustand-x/src/index.ts @@ -3,7 +3,5 @@ */ export * from './createStore'; +export * from './middlewares'; export * from './types'; -export * from './middlewares/index'; -export * from './types/index'; -export * from './utils/index'; diff --git a/packages/zustand-x/src/middlewares/devtools.ts b/packages/zustand-x/src/middlewares/devtools.ts new file mode 100644 index 0000000..d31f130 --- /dev/null +++ b/packages/zustand-x/src/middlewares/devtools.ts @@ -0,0 +1,7 @@ +import { DevtoolsOptions as _DevtoolsOptions } from 'zustand/middleware'; + +import { MiddlewareOption } from '../types'; + +export { devtools as devToolsMiddleware } from 'zustand/middleware'; + +export type DevtoolsOptions = MiddlewareOption>; diff --git a/packages/zustand-x/src/middlewares/immer.middleware.ts b/packages/zustand-x/src/middlewares/immer.middleware.ts deleted file mode 100644 index 1c63905..0000000 --- a/packages/zustand-x/src/middlewares/immer.middleware.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { produce } from 'immer'; -import { StoreApi } from 'zustand'; - -import { SetImmerState, State, StateCreatorWithDevtools } from '../types'; - -export const immerMiddleware = - ( - config: StateCreatorWithDevtools< - T, - SetImmerState, - StoreApi['getState'] - > - ): StateCreatorWithDevtools => - (set, get, api) => { - const setState: SetImmerState = (fn, actionName) => - set(produce(fn), true, actionName); - api.setState = setState as any; - - return config(setState, get, api); - }; diff --git a/packages/zustand-x/src/middlewares/immer.ts b/packages/zustand-x/src/middlewares/immer.ts new file mode 100644 index 0000000..66482cd --- /dev/null +++ b/packages/zustand-x/src/middlewares/immer.ts @@ -0,0 +1,100 @@ +import { produce } from 'immer'; + +import type { MiddlewareOption } from '../types'; +import type { Draft } from 'immer'; +import type { StateCreator, StoreMutatorIdentifier } from 'zustand'; + +declare module 'zustand' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface StoreMutators { + ['zustand/immer-x']: WithImmer; + } +} + +type Write = Omit & U; +type SkipTwo = T extends { length: 0 } + ? [] + : T extends { length: 1 } + ? [] + : T extends { length: 0 | 1 } + ? [] + : T extends [unknown, unknown, ...infer A] + ? A + : T extends [unknown, unknown?, ...infer A] + ? A + : T extends [unknown?, unknown?, ...infer A] + ? A + : never; + +type SetStateType = Exclude any>; + +type WithImmer = Write>; + +type StoreImmer = S extends { + setState: infer SetState; +} + ? SetState extends { + (...a: infer A1): infer Sr1; + (...a: infer A2): infer Sr2; + } + ? { + // Ideally, we would want to infer the `nextStateOrUpdater` `T` type from the + // `A1` type, but this is infeasible since it is an intersection with + // a partial type. + setState( + nextStateOrUpdater: + | SetStateType + | Partial> + | ((state: Draft>>) => void), + shouldReplace?: true, + ...a: SkipTwo + ): Sr1; + setState( + nextStateOrUpdater: + | SetStateType + | ((state: Draft>>) => void), + shouldReplace: false, + ...a: SkipTwo + ): Sr2; + } + : never + : never; + +type Options = { + enableMapSet?: boolean; + enabledAutoFreeze?: boolean; +}; +type ImmerImpl = ( + storeInitializer: StateCreator, + options?: Options +) => StateCreator; + +const immerImpl: ImmerImpl = (initializer) => (set, get, store) => { + type T = ReturnType; + + store.setState = (updater, replace, ...a) => { + const nextState = ( + typeof updater === 'function' ? produce(updater as any) : updater + ) as ((s: T) => T) | T | Partial; + + return set( + nextState, + typeof replace === 'boolean' ? (replace as any) : true, + ...a + ); + }; + + return initializer(store.setState, get, store); +}; + +type Immer = < + T, + Mps extends [StoreMutatorIdentifier, unknown][] = [], + Mcs extends [StoreMutatorIdentifier, unknown][] = [], +>( + initializer: StateCreator, + options?: Options +) => StateCreator; +export const immerMiddleware = immerImpl as unknown as Immer; + +export type ImmerOptions = MiddlewareOption; diff --git a/packages/zustand-x/src/middlewares/index.ts b/packages/zustand-x/src/middlewares/index.ts index 5cc309c..c5af8ad 100644 --- a/packages/zustand-x/src/middlewares/index.ts +++ b/packages/zustand-x/src/middlewares/index.ts @@ -2,4 +2,6 @@ * @file Automatically generated by barrelsby. */ -export * from './immer.middleware'; +export * from './devtools'; +export * from './immer'; +export * from './persist'; diff --git a/packages/zustand-x/src/middlewares/mutative.ts b/packages/zustand-x/src/middlewares/mutative.ts new file mode 100644 index 0000000..b62d9eb --- /dev/null +++ b/packages/zustand-x/src/middlewares/mutative.ts @@ -0,0 +1,107 @@ +import { create, PatchesOptions } from 'mutative'; + +import type { MiddlewareOption } from '../types'; +import type { Options as _MutativeOptions, Draft } from 'mutative'; +import type { StateCreator, StoreMutatorIdentifier } from 'zustand'; + +declare module 'zustand/vanilla' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface StoreMutators { + ['zustand/mutative-x']: WithMutative; + } +} + +type SetStateType = Exclude any>; +type WithMutative = Write>; + +type Write = Omit & U; +type SkipTwo = T extends { length: 0 } + ? [] + : T extends { length: 1 } + ? [] + : T extends { length: 0 | 1 } + ? [] + : T extends [unknown, unknown, ...infer A] + ? A + : T extends [unknown, unknown?, ...infer A] + ? A + : T extends [unknown?, unknown?, ...infer A] + ? A + : never; +type StoreMutative = S extends { + setState: infer SetState; +} + ? SetState extends { + (...a: infer A1): infer Sr1; + (...a: infer A2): infer Sr2; + } + ? { + // Ideally, we would want to infer the `nextStateOrUpdater` `T` type from the + // `A1` type, but this is infeasible since it is an intersection with + // a partial type. + setState( + nextStateOrUpdater: + | SetStateType + | Partial> + | ((state: Draft>>) => void), + shouldReplace?: true, + ...a: SkipTwo + ): Sr1; + setState( + nextStateOrUpdater: + | SetStateType + | ((state: Draft>>) => void), + shouldReplace: false, + ...a: SkipTwo + ): Sr2; + } + : never + : never; + +type Options = Omit< + _MutativeOptions, + 'enablePatches' +>; +type MutativeImpl = ( + storeInitializer: StateCreator, + options?: Options +) => StateCreator; + +const mutativeImpl: MutativeImpl = + (initializer, options) => (set, get, store) => { + type T = ReturnType; + + store.setState = (updater, replace, ...a) => { + const nextState = ( + typeof updater === 'function' + ? create( + updater as any, + options ? { ...options, enablePatches: false } : options + ) + : updater + ) as ((s: T) => T) | T | Partial; + + return set( + nextState as any, + typeof replace === 'boolean' ? (replace as any) : true, + ...a + ); + }; + + return initializer(store.setState, get, store); + }; + +type Mutative = < + T, + Mps extends [StoreMutatorIdentifier, unknown][] = [], + Mcs extends [StoreMutatorIdentifier, unknown][] = [], + F extends boolean = false, +>( + initializer: StateCreator, + options?: Options +) => StateCreator; + +export const mutativeMiddleware = mutativeImpl as unknown as Mutative; +export type MutativeOptions = MiddlewareOption< + Options +>; diff --git a/packages/zustand-x/src/middlewares/persist.ts b/packages/zustand-x/src/middlewares/persist.ts new file mode 100644 index 0000000..122141d --- /dev/null +++ b/packages/zustand-x/src/middlewares/persist.ts @@ -0,0 +1,8 @@ +import { PersistOptions as _PersistOptions } from 'zustand/middleware'; + +import { MiddlewareOption } from '../types'; + +export { persist as persistMiddleware } from 'zustand/middleware'; +export type PersistOptions = MiddlewareOption< + Partial<_PersistOptions> +>; diff --git a/packages/zustand-x/src/createStore.spec.ts b/packages/zustand-x/src/tests/createStore.spec.ts similarity index 64% rename from packages/zustand-x/src/createStore.spec.ts rename to packages/zustand-x/src/tests/createStore.spec.ts index ee941de..a657b73 100644 --- a/packages/zustand-x/src/createStore.spec.ts +++ b/packages/zustand-x/src/tests/createStore.spec.ts @@ -1,11 +1,18 @@ -import { createStore } from './createStore'; +import { devtools } from 'zustand/middleware'; + +import { createStore } from '../createStore'; describe('zustandX', () => { describe('when get', () => { - const store = createStore('repo')({ - name: 'zustandX', - stars: 0, - }); + const store = createStore( + devtools(() => ({ + name: 'zustandX', + stars: 0, + })), + { + name: 'repo', + } + ); it('should be', () => { expect(store.get.name()).toEqual('zustandX'); @@ -13,10 +20,15 @@ describe('zustandX', () => { }); describe('when extending actions', () => { - const store = createStore('repo')({ - name: 'zustandX', - stars: 0, - }) + const store = createStore( + { + name: 'zustandX', + stars: 0, + }, + { + name: 'repo', + } + ) .extendActions((set, get, api) => ({ validName: (name: string) => { set.name(name.trim()); @@ -40,10 +52,15 @@ describe('zustandX', () => { }); describe('when extending selectors', () => { - const store = createStore('repo')({ - name: 'zustandX ', - stars: 0, - }) + const store = createStore( + { + name: 'zustandX ', + stars: 0, + }, + { + name: 'repo', + } + ) .extendSelectors((set, get, api) => ({ validName: () => get.name().trim(), })) @@ -60,15 +77,21 @@ describe('zustandX', () => { }); describe('when set.state', () => { - const store = createStore('repo')({ - name: 'zustandX', - stars: 0, - }); + const store = createStore( + { + name: 'zustandX', + stars: 0, + }, + { + name: 'repo', + } + ); it('should be', () => { store.set.state((draft) => { draft.name = 'test'; draft.stars = 1; + return draft; }); expect(store.get.state()).toEqual({ @@ -79,13 +102,18 @@ describe('zustandX', () => { describe('deletes a property', () => { it('should delete that property', () => { - const repoStore = createStore('repo')<{ - name?: string; - stars: number; - }>({ - name: 'zustandX', - stars: 0, - }); + const repoStore = createStore( + { + name: 'zustandX', + stars: 0, + }, + { + name: 'repo', + immer: { + enabled: true, + }, + } + ); repoStore.set.state((draft) => { delete draft.name; diff --git a/packages/zustand-x/src/useStore.spec.tsx b/packages/zustand-x/src/tests/useStore.spec.tsx similarity index 85% rename from packages/zustand-x/src/useStore.spec.tsx rename to packages/zustand-x/src/tests/useStore.spec.tsx index 63d292a..a52618c 100644 --- a/packages/zustand-x/src/useStore.spec.tsx +++ b/packages/zustand-x/src/tests/useStore.spec.tsx @@ -3,7 +3,7 @@ import '@testing-library/jest-dom'; import React from 'react'; import { act, render, renderHook } from '@testing-library/react'; -import { createZustandStore } from './createStore'; +import { createZustandStore } from '../createStore'; describe('createAtomStore', () => { describe('single provider', () => { @@ -20,7 +20,10 @@ describe('createAtomStore', () => { age: INITIAL_AGE, }; - const store = createZustandStore('myTestStore')(initialTestStoreValue); + const store = createZustandStore(() => initialTestStoreValue, { + name: 'myTestStore', + immer: true, + }); const useSelectors = () => store.use; const actions = store.set; const selectors = store.get; @@ -42,7 +45,6 @@ describe('createAtomStore', () => {