├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── src └── index.ts ├── test └── all.test.tsx ├── tsconfig.json └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v2 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | env: 27 | CI: true 28 | 29 | - name: Lint 30 | run: yarn lint 31 | env: 32 | CI: true 33 | 34 | - name: Test 35 | run: yarn test --ci --coverage --maxWorkers=2 36 | env: 37 | CI: true 38 | 39 | - name: Build 40 | run: yarn build 41 | env: 42 | CI: true 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Luis Silva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Zustand Store Addons for React 2 | 3 | Create zustand stores with the leverage of powerful features inspired by Vue.js component's state management. 4 | 5 | If you're new to zustand you can read the docs [here](https://github.com/react-spring/zustand) 6 | 7 | [Live Demo](https://codesandbox.io/s/zustand-store-addons-demo-dts8y?file=/src/App.js) 8 | 9 | ## Included Features 10 | 11 | * **Computed properties**. 12 | 13 | * **Watchers**. 14 | 15 | * **Simplified fetch syntax**. 16 | 17 | * **Middleware chaining**. 18 | 19 | * **Automatic logs** for operations. 20 | 21 | ## Installation 22 | 23 | ```bash 24 | npm install zustand zustand-store-addons 25 | ``` 26 | 27 | or 28 | 29 | ```bash 30 | yarn add zustand zustand-store-addons 31 | ``` 32 | 33 | **Note: *Requires zustand version >= 3.0.0* 34 | 35 | ## But... Why not a middleware? 36 | 37 | Although middleware can help you add extra functionality *it scope is limited to what is being passed to the create function and attached once the initial state setup has completed*. Some of the included features can't be possible because of this. 38 | 39 | --- 40 | 41 | # Addons Object 42 | 43 | When we setup a store using this package we can pass an object as a second parameter to the create function with the following properties: [computed](#computed-properties-addonscomputed), [watchers](#watchers-addonswatchers), [middleware](#middleware-chaining-addonsmiddleware) and [settings](#log-settings-addonssettings). 44 | 45 | ```jsx 46 | const useStore = create((set, get) => ({ 47 | welcomeMessage: 'Hello there!' 48 | }), 49 | // Addons object 50 | { 51 | computed: {}, 52 | watchers: {}, 53 | middleware: [], 54 | settings: {} 55 | } 56 | ) 57 | ``` 58 | 59 | If the addons object is not provided the only feature we can still use would be the [simplified fetch syntax](#simplified-fetch-syntax). 60 | 61 | # How to use it, and why use it 62 | 63 | We're going to start with a conventional zustand store 64 | 65 | ```jsx 66 | import create from 'zustand-store-addons'; 67 | 68 | const useStore = create((set, get) => ({ 69 | count: 0, 70 | increment: () => set(state => ({ count: state.count + 1 })), 71 | }); 72 | 73 | export default AnotherCounterComponent() { 74 | const count = useStore(state => state.count); 75 | const increment = useStore(state => state.increment); 76 | 77 | return ( 78 |
79 |

Count: {count}

80 | 83 |
84 | ) 85 | } 86 | ``` 87 | 88 | Ok, at this point we feel the need to display **count multiplied by 2** and a **total** representing the sum of both values, so we do the following: 89 | 90 | ```jsx 91 | export default AnotherCounterComponent() { 92 | const count = useStore(state => state.count); 93 | const increment = useStore(state => state.increment); 94 | const doubleCount = count * 2; // <-- 95 | const total = count + doubleCount; // <-- 96 | 97 | return ( 98 |
99 |

Count: {count}

100 |

Count*2: {doubleCount}

101 |
102 |

Total: {total}

103 | 106 |
107 | ) 108 | } 109 | ``` 110 | 111 | We are now calculating the `doubleCount` and `total` values **inside the component**. 112 | Everything looks good until we realize that we need to **have access to these values from other components too** –that's the whole idea of using a "global/context" state management– and they are not descendants of this component (*prop drilling* is not a practical solution). 113 | 114 | Wouldn't be great if we could calculate `doubleCount` and `total` in the store? Now we can! 115 | 116 | Let's pass an object ([addons object](#addons-object)) as a second argument to the create store function with a `computed` key in order to list our **computed properties** 117 | 118 | ## Computed properties (addons.computed) 119 | 120 | ```jsx 121 | import create from 'zustand-store-addons'; 122 | 123 | const useStore = create((set, get) => ({ 124 | count: 0, 125 | increment: () => set(state => ({ count: state.count + 1 })), 126 | }), { 127 | computed: { 128 | doubleCount: function() { 129 | // `this` points to the state object 130 | return this.count * 2 131 | }, 132 | // Shorthand method definition 133 | total() { 134 | return this.count + this.doubleCount; 135 | } 136 | } 137 | }; 138 | ``` 139 | 140 | The above will result in the following state: 141 | 142 | ```jsx 143 | { 144 | count: 0, 145 | increment: function () { /* Increment fn logic */ }, 146 | doubleCount: 0, 147 | total: 0, 148 | } 149 | ``` 150 | 151 | For each key contained in the computed object, a property –named after the key– will be added to the state, and the provided function will be used as the getter function. 152 | 153 | ***Inside the getter functions we use the `this` keyword which points to the state, for this reason we should not use arrow functions to define them***. 154 | 155 | Now we need to update our component 156 | 157 | ```jsx 158 | export default AnotherCounterComponent() { 159 | // This is getting crowded... Is this the best way? 160 | const count = useStore(state => state.count); 161 | const increment = useStore(state => state.increment); 162 | const doubleCount = useStore(state => state.doubleCount); 163 | const total = useStore(state => state.total); 164 | 165 | return ( 166 |
167 |

Count: {count}

168 |

Count*2: {doubleCount}

169 |
170 |

Total: {total}

171 | 174 |
175 | ) 176 | } 177 | ``` 178 | 179 | In the code above we are *selecting* properties from the store individually, what are our options to save space or typing fatigue 😆? 180 | 181 | Perhaps use an array: 182 | 183 | ```jsx 184 | const [count, increment, doubleCount, total] = useStore( 185 | state => [state.count, state.increment, state.doubleCount, state.total] 186 | ) 187 | ``` 188 | 189 | If we leave the code above as it is right now with any change in the store –even not selected properties– our component will re-render in order to keep the pace. We don't want that behavior, let's add zustand's **shallow** function to prevent it: 190 | 191 | ```jsx 192 | const [count, increment, doubleCount, total] = useStore( 193 | state => [state.count, state.increment, state.doubleCount, state.total] 194 | , shallow) 195 | ``` 196 | 197 | Is this better? It seems repetitive. Let's take a look at a different approach **simplified fetch syntax**. 198 | 199 | ## Simplified fetch syntax 200 | 201 | We can list our selection using a string separating the properties with a comma between them. It is case-sensitive, white space is ignored and uses the **shallow** function internally. This works for a single or multiple properties. 202 | 203 | ```jsx 204 | // Single property 205 | const increment = useStore('increment'); 206 | 207 | // Returns an array when selecting multiple properties 208 | const [count, increment, doubleCount, total] = useStore( 209 | 'count, increment, doubleCount, total' 210 | ) 211 | 212 | // Or use template literals/strings if you need 213 | const times = 'double'; 214 | const [count, increment, doubleCount, total] = useStore( 215 | `count, increment, ${times}Count, total` 216 | ) 217 | ``` 218 | 219 | So, let's go back to our example and apply this to clean our component's code a little bit. 220 | 221 | ```jsx 222 | export default AnotherCounterComponent() { 223 | const [count, increment, doubleCount, total] = useStore( 224 | 'count, increment, doubleCount, total' 225 | ) 226 | 227 | return ( 228 |
229 |

Count: {count}

230 |

Count x 2: {doubleCount}

231 |
232 |

Total: {total}

233 | 236 |
237 | ) 238 | } 239 | ``` 240 | 241 | This is looking good! It's time to add logs to our store in order to see how the state is being *mutated*. We're going to use a middleware function. 242 | 243 | If we were implementing a middleware function with a standard zustand store we would need to wrap the *create* function parameters with it. If we wanted to use another one we would wrap the previous one and so on e.g., `useStore(mw2(mw1((set, get) => ({...}))))` but this is not a standard store, so we can use **middleware chaining**. 244 | 245 | ## Middleware chaining (addons.middleware) 246 | 247 | Easy way to add middleware to our stores using an array. This will apply the functions using the element's order so you don't need to worry about the wrapping. 248 | 249 | ```jsx 250 | import create from 'zustand-store-addons'; 251 | 252 | const log = config => (set, get, api) => config(args => { 253 | console.log(" applying", args) 254 | set(args) 255 | console.log(" new state", get()) 256 | }, get, api) 257 | 258 | const useStore = create((set, get) => ({ 259 | count: 0, 260 | increment: () => set(state => ({ count: state.count + 1 })), 261 | }), { 262 | computed: { 263 | doubleCount: function() { 264 | return this.count * 2 265 | }, 266 | total() { 267 | return this.count + this.doubleCount; 268 | } 269 | }, 270 | middleware: [log] // <- This is it 271 | }; 272 | 273 | ``` 274 | 275 | Great, now we're outputting the changes to the console. But we need a way to identify the logs when using multiple stores, we could modify the middleware, but... there is another way. 😎 276 | 277 | ## Log settings (addons.settings) 278 | 279 | In order to turn the logs on we need to add the settings property to the addons object. In the settings object we can set the `name` for the store and `logLevel` to `'diff'` if we want to display only the changes. Or we can use `'all'` in case we want to see the previous state, the changes and the new state. The default value for `logLevel` is `'none'`. 280 | 281 | ```jsx 282 | import create from 'zustand-store-addons'; 283 | 284 | const useStore = create((set, get) => ({ 285 | count: 0, 286 | increment: () => set(state => ({ count: state.count + 1 })), 287 | }), { 288 | computed: { 289 | doubleCount: function() { 290 | return this.count * 2 291 | }, 292 | total() { 293 | return this.count + this.doubleCount; 294 | } 295 | }, 296 | settings: { 297 | name: 'CounterStore', 298 | logLevel: 'diff' 299 | } 300 | }; 301 | 302 | ``` 303 | 304 | ### Frequently updated properties 305 | 306 | Sometimes there are properties that need to be updated very often and logging them constantly can be annoying and potentially fill the console view very quickly. For this cases we can pass a configuration object as a second argument to the `set` and `setState` functions to exclude the operation from logs. 307 | 308 | ```jsx 309 | set({ tickerSeconds: 20 }, { excludeFromLogs: true }); 310 | 311 | useStore.setState({ 312 | tickerSeconds: 20, 313 | foo: 'bar' 314 | }, { 315 | excludeFromLogs: true 316 | }); 317 | 318 | ``` 319 | 320 | ### Overwriting state 321 | 322 | Since zustand's v3.0.0 we can pass a second argument to the `set` function to replace the state instead of merge it. This can be done in the same way in this package, but if we also need to exclude the operation from logs then the use of an object is required in order to indicate both flags. 323 | 324 | ```jsx 325 | 326 | // This will replace the state 327 | useStore.setState({ 328 | tickerSeconds: 20, 329 | foo: 'bar' 330 | }, true) 331 | 332 | // This will replace the state and won't be shown in logs 333 | useStore.setState({ 334 | tickerSeconds: 20, 335 | foo: 'bar' 336 | }, { 337 | excludeFromLogs: true, 338 | replace: true 339 | }); 340 | ``` 341 | 342 | ## Watchers (addons.watchers) 343 | 344 | This feature allow us to add callbacks directly in our store that will be triggered when a certain property change. In the watchers object we add method definitions matching the method's name to the property we want to watch. The callback will be called passing the `newValue` and `prevValue` as arguments. 345 | 346 | Let's return to the example code we've been using. 347 | We might want to do something when `total` goes above 20. 348 | 349 | ```jsx 350 | import create from 'zustand-store-addons'; 351 | 352 | const useStore = create( 353 | // First argument remains the same as zustand's create function 354 | (set, get) => ({ 355 | count: 0, 356 | increment: () => set(state => ({ count: state.count + 1 })), 357 | above20: false, // <-- We add this property 358 | }), 359 | // Second argument is were you put the addons 360 | { 361 | computed: { 362 | doubleCount() { 363 | return this.count * 2 364 | }, 365 | total() { 366 | return this.count + this.doubleCount; 367 | } 368 | }, 369 | watchers: { 370 | // Will trigger every time "total" changes 371 | total(newTotal, prevTotal) { 372 | // `this` keyword gives us access to set, get and api. 373 | if (newTotal > 20 && prevTotal <= 20) { 374 | this.set({ above20: true }) 375 | } 376 | } 377 | }, 378 | settings: { 379 | name: 'CounterStore', 380 | logLevel: 'diff' 381 | } 382 | } 383 | ) 384 | 385 | ``` 386 | 387 | The ***total watcher*** will be trigger every time that `total` property changes and will set `above20` to `true` the first time the value is greater than 20. 388 | 389 | Inside any watcher function we get access to the `this` keywords which in this case points to an object that contains zustand's `set` and `get` methods and `api` object. 390 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['src', 'test'], 3 | testEnvironment: 'jest-environment-jsdom-sixteen', 4 | moduleDirectories: ['src', 'node_modules'], 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.12", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "keywords": [ 14 | "react", 15 | "state", 16 | "manager", 17 | "management", 18 | "store", 19 | "zustand", 20 | "vue", 21 | "computed", 22 | "watcher" 23 | ], 24 | "scripts": { 25 | "start": "tsdx watch", 26 | "build": "tsdx build", 27 | "test": "tsdx test --passWithNoTests", 28 | "lint": "tsdx lint", 29 | "prepare": "tsdx build" 30 | }, 31 | "peerDependencies": { 32 | "react": ">=16", 33 | "zustand": ">=3.0.0" 34 | }, 35 | "husky": { 36 | "hooks": { 37 | "pre-commit": "tsdx lint" 38 | } 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "https://github.com/Diablow/zustand-store-addons.git" 43 | }, 44 | "prettier": { 45 | "printWidth": 80, 46 | "semi": true, 47 | "singleQuote": true, 48 | "trailingComma": "es5" 49 | }, 50 | "name": "zustand-store-addons", 51 | "author": "Luis Silva", 52 | "module": "dist/zustand-store-addons.esm.js", 53 | "devDependencies": { 54 | "@testing-library/react": "^10.4.9", 55 | "@types/lodash": "^4.14.161", 56 | "@types/react": "^16.9.49", 57 | "@types/react-dom": "^16.9.8", 58 | "husky": "^4.2.5", 59 | "jest-environment-jsdom-sixteen": "^1.0.3", 60 | "react": "^16.13.1", 61 | "react-dom": "^16.13.1", 62 | "tsdx": "^0.13.3", 63 | "tslib": "^2.2.0", 64 | "typescript": "^4.0.2", 65 | "zustand": "^3.0.3" 66 | }, 67 | "dependencies": { 68 | "lodash-es": "^4.17.15", 69 | "string.prototype.matchall": "^4.0.2" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import create, { 2 | State, 3 | StateSelector, 4 | EqualityChecker, 5 | GetState, 6 | Subscribe, 7 | Destroy, 8 | StoreApi, 9 | } from 'zustand'; 10 | import shallow from 'zustand/shallow'; 11 | import flow from 'lodash/flow'; 12 | // @ts-ignore 13 | import matchAll from 'string.prototype.matchall'; 14 | import unset from 'lodash/unset'; 15 | import hasIn from 'lodash/hasIn'; 16 | import isEqual from 'lodash/isEqual'; 17 | import _get from 'lodash/get'; 18 | 19 | matchAll.shim(); 20 | 21 | type TStateRecords = Record; 22 | 23 | export enum LogLevel { 24 | None = 'none', 25 | Diff = 'diff', 26 | All = 'all', 27 | } 28 | 29 | interface IAddonsSettings { 30 | name?: string; 31 | logLevel?: LogLevel | string; 32 | } 33 | 34 | interface SetStateSettings { 35 | excludeFromLogs?: boolean | undefined; 36 | replace?: boolean | undefined; 37 | } 38 | 39 | // PartialState has been deprecated by zustand: 40 | // https://github.com/pmndrs/zustand/blob/a418fd748077c453efbff2d03641ce0af780b3c7/src/vanilla.ts#L6 41 | type PartialState = Partial | ((state: T) => Partial); 42 | 43 | type SetState = ( 44 | partial: PartialState, 45 | replace?: boolean 46 | ) => void; 47 | type SetStateAddons = ( 48 | partial: PartialState, 49 | setSettings?: SetStateSettings 50 | ) => void; 51 | declare type StateCreator< 52 | T extends State, 53 | CustomSetState = SetState | SetStateAddons 54 | > = (set: CustomSetState, get: GetState, api: StoreApi) => T; 55 | 56 | type SetStateExtraParam = boolean | undefined | SetStateSettings; 57 | 58 | interface IAddons { 59 | computed?: TStateRecords; 60 | watchers?: TStateRecords; 61 | middleware?: Array; 62 | settings?: IAddonsSettings; 63 | } 64 | 65 | function getDeps(fn: (state: State) => any) { 66 | const reg = new RegExp(/(?:this\.)(\w+)/g); 67 | const reg2 = new RegExp(/(?:this\[')(\w+)(?:'\])/g); 68 | const matches = Array.from(fn.toString().matchAll(reg)).map( 69 | match => match[1] 70 | ); 71 | const matches2 = Array.from(fn.toString().matchAll(reg2)).map( 72 | match => match[1] 73 | ); 74 | // @ts-ignore 75 | const mergedMatches = Array.from(new Set([...matches, ...matches2])); 76 | return mergedMatches; 77 | } 78 | 79 | function intersection(aArray: any[], bArray: any) { 80 | let intersections = []; 81 | for (let elem of bArray) { 82 | if (aArray.includes(elem)) { 83 | intersections.push(elem); 84 | } 85 | } 86 | return intersections; 87 | } 88 | 89 | export interface UseStore { 90 | (): T; 91 | ( 92 | selector: StateSelector | string, 93 | equalityFn?: EqualityChecker 94 | ): U; 95 | setState: SetState | SetStateAddons; 96 | getState: GetState; 97 | subscribe: Subscribe; 98 | destroy: Destroy; 99 | } 100 | 101 | export default function createStore( 102 | stateInitializer: StateCreator, 103 | addons?: IAddons 104 | ): UseStore { 105 | let _api: StoreApi; 106 | let _originalSetState: SetState; 107 | let _computed: TStateRecords = {}; 108 | let updatedComputed: Record = {}; 109 | let _computedMerged: boolean = false; 110 | let _computedPropDependencies: TStateRecords = {}; 111 | let _watchers: TStateRecords = {}; 112 | let _settings: IAddonsSettings = { 113 | name: addons?.settings?.name ?? 'MyStore', 114 | logLevel: addons?.settings?.logLevel ?? LogLevel.None, 115 | }; 116 | 117 | function attachMiddleWare(config: StateCreator) { 118 | return ( 119 | set: SetState, 120 | get: GetState, 121 | api: StoreApi 122 | ) => { 123 | // Overwrites set method 124 | return config( 125 | (args: any, setSettings: SetStateExtraParam) => { 126 | return setMiddleware([args, setSettings], set); 127 | }, 128 | get, 129 | api 130 | ); 131 | }; 132 | } 133 | 134 | // Overwrites setState method 135 | function setState(args: any, setSettings: SetStateExtraParam) { 136 | setMiddleware([args, setSettings], _originalSetState); 137 | } 138 | 139 | function setMiddleware(args: any, set: any, recursiveOp: boolean = false) { 140 | const [partialState, setSettings] = args; 141 | 142 | let replaceState = false; 143 | let excludeFromLogs = false; 144 | 145 | if (typeof setSettings !== 'undefined') { 146 | if (typeof setSettings === 'boolean') { 147 | replaceState = setSettings; 148 | } else if (typeof setSettings === 'object') { 149 | replaceState = setSettings.replace || false; 150 | excludeFromLogs = setSettings.excludeFromLogs || false; 151 | } 152 | } 153 | 154 | let logOperations = 155 | (!excludeFromLogs ?? true) && 156 | _computedMerged && 157 | (_settings.logLevel as string) !== (LogLevel.None as string); 158 | 159 | const currentState = _api.getState(); 160 | const changes = 161 | typeof partialState === 'function' 162 | ? partialState(currentState) 163 | : partialState; 164 | 165 | const group = `${_settings.name} state changed`; 166 | logOperations && !recursiveOp && console.group(group); 167 | logOperations && 168 | !recursiveOp && 169 | (_settings.logLevel as string) === (LogLevel.All as string) && 170 | console.log('Previous State', currentState); 171 | 172 | logOperations && !recursiveOp && console.log('Applying', changes); 173 | 174 | updatedComputed = {}; 175 | 176 | if (!replaceState) { 177 | for (const [compPropName, compPropDeps] of Object.entries( 178 | _computedPropDependencies 179 | )) { 180 | const needsRecompute = intersection(Object.keys(changes), compPropDeps); 181 | if (needsRecompute.length > 0) { 182 | updatedComputed[compPropName] = _computed[compPropName].apply( 183 | { 184 | ...currentState, 185 | ...changes, 186 | ...updatedComputed, 187 | }, 188 | [ 189 | { 190 | set: _api.setState, 191 | get: _api.getState, 192 | }, 193 | ] 194 | ); 195 | } 196 | } 197 | } 198 | 199 | if (Object.keys(updatedComputed).length > 0) { 200 | logOperations && console.log('Updating computed values', updatedComputed); 201 | } 202 | 203 | const newState = { ...changes, ...updatedComputed }; 204 | 205 | // Clean computed properties and watchers if they are no longer in the state 206 | if (replaceState) { 207 | for (const [compPropName] of Object.entries(_computed)) { 208 | if (!(compPropName in newState)) { 209 | delete _computed[compPropName]; 210 | delete _computedPropDependencies[compPropName]; 211 | } 212 | } 213 | 214 | for (const [watcherName] of Object.entries(_watchers)) { 215 | if (!(watcherName in newState)) { 216 | unset(_watchers, watcherName); 217 | } 218 | } 219 | } 220 | 221 | set(newState, replaceState); 222 | 223 | for (const [propName, fn] of Object.entries(_watchers)) { 224 | let newVal = _get(newState, propName), 225 | currentVal = _get(currentState, propName); 226 | 227 | if (hasIn(newState, propName) && !isEqual(newVal, currentVal)) { 228 | logOperations && console.log(`Triggering watcher: ${propName}`); 229 | fn.apply({ set: _api.setState, get: _api.getState, api: _api }, [ 230 | newVal, 231 | currentVal, 232 | ]); 233 | } 234 | } 235 | 236 | if (Object.keys(updatedComputed).length > 0 && !replaceState) { 237 | setMiddleware([updatedComputed, setSettings], _originalSetState, true); 238 | } 239 | 240 | logOperations && 241 | (_settings.logLevel as string) === (LogLevel.All as string) && 242 | !recursiveOp && 243 | console.log('New State', _api.getState()); 244 | 245 | logOperations && !recursiveOp && console.groupEnd(); 246 | } 247 | 248 | const middlewareFunctions = ( 249 | middleware: 250 | | Array<() => StateCreator> 251 | | undefined, 252 | store: StateCreator 253 | ) => (middleware ? flow(middleware)(store) : store); 254 | 255 | function configComputed(computed: TStateRecords) { 256 | let computedToAdd = {}; 257 | for (const [key, value] of Object.entries(computed)) { 258 | const deps = getDeps(value); 259 | if (deps.length > 0) { 260 | _computedPropDependencies[key] = deps; 261 | _computed[key] = value; 262 | } 263 | computedToAdd = { 264 | ...computedToAdd, 265 | [key]: value.apply({ ..._api.getState(), ...computedToAdd }, [ 266 | { set: _api.setState, get: _api.getState }, 267 | ]), 268 | }; 269 | } 270 | if (Object.keys(computedToAdd).length > 0) { 271 | _api.setState(computedToAdd); 272 | } 273 | 274 | _computedMerged = true; 275 | } 276 | 277 | function configWatchers(watchers: TStateRecords) { 278 | for (const [propName, fn] of Object.entries(watchers)) { 279 | _watchers[propName] = fn; 280 | } 281 | } 282 | 283 | function _stateCreator( 284 | set: SetState | SetStateAddons, 285 | get: GetState, 286 | api: StoreApi 287 | ): TState { 288 | _api = api; 289 | _originalSetState = { ...api }.setState; 290 | _api.setState = setState; 291 | return { 292 | ...stateInitializer( 293 | set as SetState | SetStateAddons, 294 | get, 295 | api 296 | ), 297 | }; 298 | } 299 | 300 | const hook = create( 301 | attachMiddleWare(middlewareFunctions(addons?.middleware, _stateCreator)) 302 | ); 303 | 304 | const stateHook: any = (selector: any, equalityFn?: any) => { 305 | if (typeof selector === 'string') { 306 | if (selector.indexOf(',') !== -1) { 307 | const props = selector.split(',').map(part => part.trim()); 308 | return hook(state => props.map(prop => state[prop]), shallow); 309 | } 310 | return hook(state => state[selector], equalityFn); 311 | } 312 | return hook(selector, equalityFn); 313 | }; 314 | 315 | // @ts-ignore 316 | Object.assign(stateHook, _api); 317 | 318 | configComputed(addons?.computed ?? {}); 319 | configWatchers(addons?.watchers ?? {}); 320 | 321 | return stateHook; 322 | } 323 | -------------------------------------------------------------------------------- /test/all.test.tsx: -------------------------------------------------------------------------------- 1 | import create from '../src'; 2 | import React from 'react'; 3 | // import ReactDOM from 'react-dom'; 4 | import { render } from '@testing-library/react'; 5 | 6 | it('creates a store hook and api object', () => { 7 | let params; 8 | const result = create((...args) => { 9 | params = args; 10 | return { value: null }; 11 | }); 12 | expect({ params, result }).toMatchInlineSnapshot(` 13 | Object { 14 | "params": Array [ 15 | [Function], 16 | [Function], 17 | Object { 18 | "destroy": [Function], 19 | "getState": [Function], 20 | "setState": [Function], 21 | "subscribe": [Function], 22 | }, 23 | ], 24 | "result": [Function], 25 | } 26 | `); 27 | }); 28 | 29 | it('creates a store hook and api object passing empty addons', () => { 30 | let params; 31 | const result = create((...args) => { 32 | params = args; 33 | return { value: null }; 34 | }, {}); 35 | expect({ params, result }).toMatchInlineSnapshot(` 36 | Object { 37 | "params": Array [ 38 | [Function], 39 | [Function], 40 | Object { 41 | "destroy": [Function], 42 | "getState": [Function], 43 | "setState": [Function], 44 | "subscribe": [Function], 45 | }, 46 | ], 47 | "result": [Function], 48 | } 49 | `); 50 | }); 51 | 52 | it('creates a store hook and api object passing addons obj', () => { 53 | let params; 54 | const result = create( 55 | (...args) => { 56 | params = args; 57 | return { count: 0 }; 58 | }, 59 | { 60 | computed: { 61 | doubleCount() { 62 | return this.count * 2; 63 | }, 64 | }, 65 | watchers: { 66 | count(newValue: number, oldValue: number) { 67 | console.log(newValue, oldValue); 68 | }, 69 | }, 70 | middleware: [], 71 | settings: { 72 | name: 'TestingStore', 73 | logLevel: 'diff', 74 | }, 75 | } 76 | ); 77 | expect({ params, result }).toMatchInlineSnapshot(` 78 | Object { 79 | "params": Array [ 80 | [Function], 81 | [Function], 82 | Object { 83 | "destroy": [Function], 84 | "getState": [Function], 85 | "setState": [Function], 86 | "subscribe": [Function], 87 | }, 88 | ], 89 | "result": [Function], 90 | } 91 | `); 92 | }); 93 | 94 | it('uses the store with no args', async () => { 95 | const useStore = create( 96 | set => ({ 97 | count: 1, 98 | inc: () => set(state => ({ count: state.count + 1 })), 99 | }), 100 | { 101 | computed: { 102 | doubleCount() { 103 | return this.count * 2; 104 | }, 105 | }, 106 | } 107 | ); 108 | 109 | function Counter() { 110 | const { count, doubleCount, inc } = useStore(); 111 | React.useEffect(inc, []); 112 | return ( 113 |
114 |

count: {count}

115 |

doubleCount: {doubleCount}

116 |
117 | ); 118 | } 119 | 120 | const { findByText } = render(); 121 | 122 | await findByText('count: 2'); 123 | await findByText('doubleCount: 4'); 124 | }); 125 | 126 | it('uses the store with selectors', async () => { 127 | const useStore = create( 128 | (set, get) => ({ 129 | count: 1, 130 | inc: () => set({ count: get().count + 1 }), 131 | }), 132 | { 133 | computed: { 134 | doubleCount() { 135 | return this.count * 2; 136 | }, 137 | }, 138 | } 139 | ); 140 | 141 | function Counter() { 142 | const count = useStore(s => s.count); 143 | const doubleCount = useStore(s => s.doubleCount); 144 | const inc = useStore(s => s.inc); 145 | React.useEffect(inc, []); 146 | return ( 147 |
148 |

count: {count}

149 |

doubleCount: {doubleCount}

150 |
151 | ); 152 | } 153 | 154 | const { findByText } = render(); 155 | 156 | await findByText('count: 2'); 157 | await findByText('doubleCount: 4'); 158 | }); 159 | 160 | it('uses the store with simplified fetch', async () => { 161 | const useStore = create( 162 | set => ({ 163 | count: 1, 164 | inc: () => set(state => ({ count: state.count + 1 })), 165 | }), 166 | { 167 | computed: { 168 | doubleCount() { 169 | return this.count * 2; 170 | }, 171 | }, 172 | } 173 | ); 174 | 175 | function Counter() { 176 | const [count, doubleCount, inc] = useStore('count, doubleCount, inc'); 177 | React.useEffect(inc, []); 178 | return ( 179 |
180 |

count: {count}

181 |

doubleCount: {doubleCount}

182 |
183 | ); 184 | } 185 | 186 | const { findByText } = render(); 187 | 188 | await findByText('count: 2'); 189 | await findByText('doubleCount: 4'); 190 | }); 191 | 192 | it('uses the store with simplified fetch and watchers', async () => { 193 | // Force state type so that the typings are verified without adding extra test time 194 | // e.g. make sure that https://github.com/Diablow/zustand-store-addons/issues/2 195 | // does not reappears 196 | const useStore = create<{ count: number; moreThan5: boolean }>( 197 | set => ({ 198 | count: 1, 199 | inc: () => set(state => ({ count: state.count + 1 })), 200 | moreThan5: false, 201 | }), 202 | { 203 | computed: { 204 | doubleCount() { 205 | return this.count * 2; 206 | }, 207 | total() { 208 | return this.count + this.doubleCount; 209 | }, 210 | }, 211 | watchers: { 212 | total(newValue: number, oldValue: number) { 213 | if (newValue > 5 && oldValue <= 5) { 214 | this.set({ moreThan5: true }); 215 | } 216 | }, 217 | }, 218 | } 219 | ); 220 | 221 | function Counter() { 222 | const [count, doubleCount, total, moreThan5, inc] = useStore( 223 | 'count, doubleCount, total, moreThan5, inc' 224 | ); 225 | React.useEffect(inc, []); 226 | return ( 227 |
228 |

count: {count}

229 |

doubleCount: {doubleCount}

230 |

total: {total}

231 | {moreThan5 &&

More than 5

} 232 |
233 | ); 234 | } 235 | 236 | const { findByText } = render(); 237 | 238 | await findByText('count: 2'); 239 | await findByText('doubleCount: 4'); 240 | await findByText('total: 6'); 241 | await findByText('More than 5'); 242 | }); 243 | 244 | it('uses the store with simplified fetch and watchers but replacing state from watcher', async () => { 245 | const useStore = create( 246 | set => ({ 247 | count: 1, 248 | inc: () => set(state => ({ count: state.count + 1 })), 249 | moreThan5: false, 250 | }), 251 | { 252 | computed: { 253 | doubleCount() { 254 | return this.count * 2; 255 | }, 256 | total() { 257 | return this.count === undefined || this.doubleCount === undefined 258 | ? 0 259 | : this.count + this.doubleCount; 260 | }, 261 | }, 262 | watchers: { 263 | total(newValue: number, oldValue: number) { 264 | if (newValue > 5 && oldValue <= 5) { 265 | this.set({ moreThan5: true }, { replace: true }); 266 | } 267 | }, 268 | }, 269 | settings: { 270 | name: 'rudeTest', 271 | logLevel: 'all', 272 | }, 273 | } 274 | ); 275 | 276 | function Counter() { 277 | const [count, doubleCount, total, moreThan5, inc] = useStore( 278 | 'count, doubleCount, total, moreThan5, inc' 279 | ); 280 | React.useEffect(inc, []); 281 | return ( 282 |
283 |

count: {count}

284 |

doubleCount: {doubleCount}

285 |

total: {total}

286 | {moreThan5 &&

More than 5

} 287 |
288 | ); 289 | } 290 | 291 | const { findByText } = render(); 292 | 293 | await findByText('More than 5'); 294 | expect(useStore.getState()).toEqual({ moreThan5: true }); 295 | }); 296 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "jsx": "react", 17 | "esModuleInterop": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------