diff --git a/.changeset/witty-games-raise.md b/.changeset/witty-games-raise.md new file mode 100644 index 0000000..ec71008 --- /dev/null +++ b/.changeset/witty-games-raise.md @@ -0,0 +1,33 @@ +--- +"@udecode/zustood": minor +--- + +`react-tracked` support + +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: + +```tsx +// Global tracked hook selectors +export const useTrackedStore = () => mapValuesKey('useTracked', rootStore); + +// 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.owner.email === next.owner.email) + return ( +
+ User Email: {owner.email} +
+ ); +}; +``` diff --git a/README.md b/README.md index 4dc0e15..5550ca4 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ API. - Derived actions - `immer`, `devtools` and `persist` middlewares - Full typescript support +- `react-tracked` support ## Create a store @@ -40,6 +41,10 @@ import { createStore } from '@udecode/zustood' const repoStore = createStore('repo')({ name: 'zustood', stars: 0, + owner: { + name: 'someone', + email: 'someone@xxx.com', + }, }) ``` @@ -75,6 +80,17 @@ 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 @@ -182,6 +198,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); @@ -202,7 +221,32 @@ useStore().modal.isOpen() useStore().repo.middlewares(shallow) ``` -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.owner.email === next.owner.email) + return ( +
+ User Email: {owner.email} +
+ ); +}; +``` + +By using `useStore() or useTrackStore()`, ESLint will correctly lint hook errors. ### Global getter selectors diff --git a/packages/zustood/package.json b/packages/zustood/package.json index 5276297..762da37 100644 --- a/packages/zustood/package.json +++ b/packages/zustood/package.json @@ -30,7 +30,8 @@ "test": "jest" }, "dependencies": { - "immer": "^9.0.6" + "immer": "^9.0.6", + "react-tracked": "^1.7.9" }, "peerDependencies": { "zustand": ">=3.5.10" diff --git a/packages/zustood/src/createStore.ts b/packages/zustood/src/createStore.ts index 4d07b5d..7dde13c 100644 --- a/packages/zustood/src/createStore.ts +++ b/packages/zustood/src/createStore.ts @@ -1,4 +1,5 @@ import { setAutoFreeze, enableMapSet } from 'immer'; +import { createTrackedSelector } from 'react-tracked'; import create, { State, StateCreator } from 'zustand'; import { devtools as devtoolsMiddleware, @@ -18,6 +19,7 @@ import { generateStateActions } from './utils/generateStateActions'; import { storeFactory } from './utils/storeFactory'; import { generateStateGetSelectors } from './utils/generateStateGetSelectors'; import { generateStateHookSelectors } from './utils/generateStateHookSelectors'; +import { generateStateTrackedHooksSelectors } from './utils/generateStateTrackedHooksSelectors'; import { immerMiddleware } from './middlewares/immer.middleware'; import { pipe } from './utils/pipe'; import { CreateStoreOptions } from './types/CreateStoreOptions'; @@ -77,6 +79,12 @@ export const createStore = const hookSelectors = generateStateHookSelectors(useStore); const getterSelectors = generateStateGetSelectors(useStore); + const useTrackedStore = createTrackedSelector(useStore); + const trackedHooksSelectors = generateStateTrackedHooksSelectors( + useStore, + useTrackedStore + ); + const api = { get: { state: store.getState, @@ -90,7 +98,9 @@ export const createStore = } as StateActions, store, use: hookSelectors, + useTracked: trackedHooksSelectors, useStore, + useTrackedStore, extendSelectors: () => api as any, extendActions: () => api as any, }; diff --git a/packages/zustood/src/types.ts b/packages/zustood/src/types.ts index 3385cb3..2aab75f 100644 --- a/packages/zustood/src/types.ts +++ b/packages/zustood/src/types.ts @@ -9,6 +9,10 @@ export type StoreApiGet< > = StateGetters & TSelectors; export type StoreApiUse = GetRecord & TSelectors; +export type StoreApiUseTracked< + T extends State = {}, + TSelectors = {} +> = GetRecord & TSelectors; export type StoreApiSet = TActions; export type StoreApi< @@ -22,7 +26,9 @@ export type StoreApi< set: StoreApiSet; store: ImmerStoreApi; use: StoreApiUse; + useTracked: StoreApiUseTracked; useStore: UseImmerStore; + useTrackedStore: () => T; extendSelectors>( builder: SB diff --git a/packages/zustood/src/utils/extendSelectors.ts b/packages/zustood/src/utils/extendSelectors.ts index a9b3122..8eb066c 100644 --- a/packages/zustood/src/utils/extendSelectors.ts +++ b/packages/zustood/src/utils/extendSelectors.ts @@ -5,6 +5,7 @@ import { StoreApi, StoreApiGet, StoreApiUse, + StoreApiUseTracked, } from '../types'; export const extendSelectors = < @@ -26,6 +27,10 @@ export const extendSelectors = < ...api.use, } as StoreApiUse>; + const useTracked = { + ...api.useTracked, + } as StoreApiUseTracked>; + const get = { ...api.get, } as StoreApiGet>; @@ -35,6 +40,9 @@ export const extendSelectors = < use[key] = (...args: any[]) => api.useStore((state) => builder(state, api.get, api)[key])(...args); // @ts-ignore + useTracked[key] = (...args: any[]) => + api.useStore((state) => builder(state, api.get, api)[key])(...args); + // @ts-ignore get[key] = (...args: any[]) => builder(api.store.getState(), api.get, api)[key](...args); }); diff --git a/packages/zustood/src/utils/generateStateTrackedHooksSelectors.ts b/packages/zustood/src/utils/generateStateTrackedHooksSelectors.ts new file mode 100644 index 0000000..883420c --- /dev/null +++ b/packages/zustood/src/utils/generateStateTrackedHooksSelectors.ts @@ -0,0 +1,17 @@ +import { State } from 'zustand'; +import { GetRecord, UseImmerStore } from '../types'; + +export const generateStateTrackedHooksSelectors = ( + store: UseImmerStore, + trackedStore: () => T +) => { + const selectors: GetRecord = {} as any; + + Object.keys(store.getState()).forEach((key) => { + selectors[key] = () => { + return trackedStore()[key as keyof T]; + }; + }); + + return selectors; +}; diff --git a/yarn.lock b/yarn.lock index 04fff44..630104b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3585,6 +3585,7 @@ __metadata: resolution: "@udecode/zustood@workspace:packages/zustood" dependencies: immer: ^9.0.6 + react-tracked: ^1.7.9 peerDependencies: zustand: ">=3.5.10" languageName: unknown @@ -11502,6 +11503,13 @@ __metadata: languageName: node linkType: hard +"proxy-compare@npm:2.1.0": + version: 2.1.0 + resolution: "proxy-compare@npm:2.1.0" + checksum: e431403abbb52468045635f434846c55b388c3ccf4012efe729e3fa846513b5d49e2488328582d5be792e7b9b0ba5f5a111887b3a2c4f9a273fc432ab79c7b63 + languageName: node + linkType: hard + "prr@npm:~0.0.0": version: 0.0.0 resolution: "prr@npm:0.0.0" @@ -11721,6 +11729,26 @@ __metadata: languageName: node linkType: hard +"react-tracked@npm:^1.7.9": + version: 1.7.9 + resolution: "react-tracked@npm:1.7.9" + dependencies: + proxy-compare: 2.1.0 + use-context-selector: 1.3.10 + peerDependencies: + react: ">=16.8.0" + react-dom: "*" + react-native: "*" + scheduler: ">=0.19.0" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: f8b173603173fd764263fc7e5db304482169faed32a2d528d4414d9ec60dce58c7bba0eba617f91e2e6a6af12900c4b8eb49c2caa1f7e0ac1f848f35db01b15c + languageName: node + linkType: hard + "react@npm:^17.0.1": version: 17.0.2 resolution: "react@npm:17.0.2" @@ -14131,6 +14159,23 @@ typescript@^4.4.3: languageName: node linkType: hard +"use-context-selector@npm:1.3.10": + version: 1.3.10 + resolution: "use-context-selector@npm:1.3.10" + peerDependencies: + react: ">=16.8.0" + react-dom: "*" + react-native: "*" + scheduler: ">=0.19.0" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 86fe17bb25dc2c6730e83911b3baf46fbc1be45f27c86bf2f94b4ed55d443572c6a5d725b77cf6a2ae1ddfe388bfbd6850851591ca1a26e7e216e5aa58b2631d + languageName: node + linkType: hard + "use@npm:^3.1.0": version: 3.1.1 resolution: "use@npm:3.1.1"