├── .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 |