├── src ├── index.ts ├── computed.ts └── computed.test.ts ├── .gitignore ├── bun.lockb ├── commitlint.config.mjs ├── .husky └── commit-msg ├── tsconfig.json ├── vite.config.ts ├── biome.json ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── package.json ├── CHANGELOG.md └── README.md /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./computed" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | .DS_Store 4 | yarn-error.log 5 | node_modules 6 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisvander/zustand-computed/HEAD/bun.lockb -------------------------------------------------------------------------------- /commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { extends: ["@commitlint/config-conventional"] } 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/bun/tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["**/*/*.test.*", "node_modules", "dist", "coverage", "example"] 5 | } 6 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path" 2 | import { defineConfig } from "vite" 3 | import dts from "vite-plugin-dts" 4 | 5 | export default defineConfig({ 6 | plugins: [dts()], 7 | build: { 8 | minify: false, 9 | lib: { 10 | entry: path.resolve(__dirname, "src/index.ts"), 11 | fileName: "index", 12 | formats: ["es", "cjs"], 13 | }, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true 10 | } 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "lineWidth": 120, 16 | "ignore": ["dist/**/*", "node_modules/**/*"] 17 | }, 18 | "javascript": { 19 | "formatter": { 20 | "semicolons": "asNeeded" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Christian van der Loo 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. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test & Build 2 | 3 | permissions: 4 | id-token: write 5 | contents: read 6 | 7 | on: 8 | push: 9 | branches: ["main"] 10 | pull_request: 11 | branches: ["main"] 12 | 13 | jobs: 14 | test-and-build: 15 | name: Test & Build 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: oven-sh/setup-bun@v2 20 | with: 21 | bun-version: latest 22 | - run: bun i 23 | - run: bun test 24 | - run: bun run build 25 | release-please: 26 | name: Release Please 27 | runs-on: ubuntu-latest 28 | outputs: 29 | release_created: ${{ steps.release.outputs.release_created }} 30 | steps: 31 | - id: release 32 | uses: google-github-actions/release-please-action@v3 33 | with: 34 | release-type: node 35 | package-name: zustand-computed 36 | release: 37 | name: Create NPM Release 38 | runs-on: ubuntu-latest 39 | needs: [release-please, test-and-build] 40 | if: ${{ needs.release-please.outputs.release_created }} 41 | steps: 42 | - uses: actions/checkout@v2 43 | - uses: oven-sh/setup-bun@v2 44 | with: 45 | bun-version: latest 46 | registry-url: https://registry.npmjs.org/ 47 | - run: bun i 48 | - run: bun run build 49 | - run: bunx npm publish 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zustand-computed", 3 | "version": "2.1.1", 4 | "author": "chrisvander", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/chrisvander/zustand-computed.git" 8 | }, 9 | "main": "dist/index.js", 10 | "module": "dist/index.mjs", 11 | "types": "dist/index.d.ts", 12 | "devDependencies": { 13 | "@biomejs/biome": "^1.9.4", 14 | "@commitlint/cli": "^19.5.0", 15 | "@commitlint/config-conventional": "^19.5.0", 16 | "@tsconfig/bun": "^1.0.7", 17 | "@types/bun": "^1.1.13", 18 | "@types/node": "^22.9.0", 19 | "husky": "^9.1.6", 20 | "immer": "^10.1.3", 21 | "react": "^18.3.1", 22 | "semver": "^7.6.3", 23 | "typescript": "^5.6.3", 24 | "vite": "^5.4.10", 25 | "vite-plugin-dts": "^4.3.0", 26 | "zustand": "^5.0.0" 27 | }, 28 | "peerDependencies": { 29 | "react": ">=18.2.0 <20.0.0", 30 | "zustand": ">=5.0.0 <6.0.0" 31 | }, 32 | "exports": { 33 | ".": { 34 | "import": "./dist/index.js", 35 | "require": "./dist/index.mjs", 36 | "types": "./dist/index.d.ts" 37 | } 38 | }, 39 | "description": "A Zustand middleware to create computed states.", 40 | "files": [ 41 | "/dist" 42 | ], 43 | "keywords": [ 44 | "zustand", 45 | "computed", 46 | "calculated", 47 | "state", 48 | "react", 49 | "plugin", 50 | "middleware", 51 | "npm", 52 | "typescript" 53 | ], 54 | "license": "MIT", 55 | "scripts": { 56 | "check": "biome check --write", 57 | "format": "biome format --write", 58 | "lint": "biome lint", 59 | "build": "vite build" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [2.1.1](https://github.com/chrisvander/zustand-computed/compare/v2.1.0...v2.1.1) (2025-10-22) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * immer middleware throwing ([a599978](https://github.com/chrisvander/zustand-computed/commit/a5999784857045aba26f73e8aad5d4dfcb9241b4)) 9 | 10 | ## [2.1.0](https://github.com/chrisvander/zustand-computed/compare/v2.0.2...v2.1.0) (2025-05-17) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * refactor, add docs, remove proxy (bad default behavior) ([a41e42c](https://github.com/chrisvander/zustand-computed/commit/a41e42c5a61751f3fcc8be59da0cbcc100bd013c)) 16 | 17 | 18 | ### Miscellaneous Chores 19 | 20 | * release 2.1.0 ([d004343](https://github.com/chrisvander/zustand-computed/commit/d00434397228658647479a77f4b8edcd99343d03)) 21 | * release 2.1.0 ([10fd7bc](https://github.com/chrisvander/zustand-computed/commit/10fd7bc69d79a2c3126855a30bf165579d914e8d)) 22 | 23 | ## [2.0.2](https://github.com/chrisvander/zustand-computed/compare/v2.0.1...v2.0.2) (2024-11-10) 24 | 25 | 26 | ### Miscellaneous Chores 27 | 28 | * release 2.0.2 ([8de1ae1](https://github.com/chrisvander/zustand-computed/commit/8de1ae1eb47d034efec1145b9d2d8a0f0827b437)) 29 | 30 | ## [2.0.1](https://github.com/chrisvander/zustand-computed/compare/v2.0.0...v2.0.1) (2024-11-10) 31 | 32 | 33 | ### Miscellaneous Chores 34 | 35 | * release 2.0.1 ([4b27994](https://github.com/chrisvander/zustand-computed/commit/4b279942910fdb5450057c5fd7eb5c1fad9e44fb)) 36 | 37 | ## [2.0.0](https://github.com/chrisvander/zustand-computed/compare/v1.4.2...v2.0.0) (2024-08-23) 38 | 39 | 40 | ### ⚠ BREAKING CHANGES 41 | 42 | * replace `computed` middleware with `createComputed` function 43 | 44 | ### Features 45 | 46 | * replace `computed` middleware with `createComputed` function ([db30ae9](https://github.com/chrisvander/zustand-computed/commit/db30ae959fa67e13f527141ff9181521eb515367)) 47 | 48 | ## [1.4.2](https://github.com/chrisvander/zustand-computed/compare/v1.4.1...v1.4.2) (2024-08-22) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * use type imports, organize imports ([d3f007b](https://github.com/chrisvander/zustand-computed/commit/d3f007ba3db24b2c1d14576e33f0eca61b329a4f)) 54 | 55 | ## [1.4.1](https://github.com/chrisvander/zustand-computed/compare/v1.4.0...v1.4.1) (2024-04-18) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | * module zustand -> zustand/vanilla ([#18](https://github.com/chrisvander/zustand-computed/issues/18)) ([6feb628](https://github.com/chrisvander/zustand-computed/commit/6feb628fdb1bf3a924cdb438e903f09471845b9e)) 61 | 62 | ## [1.4.0](https://github.com/chrisvander/zustand-computed/compare/v1.3.7...v1.4.0) (2024-03-03) 63 | 64 | 65 | ### Features 66 | 67 | * support slices pattern type ([#16](https://github.com/chrisvander/zustand-computed/issues/16)) ([043d1b6](https://github.com/chrisvander/zustand-computed/commit/043d1b6cbce8bbae104b072f8a6b97d637b9dd06)) 68 | 69 | 70 | ### Bug Fixes 71 | 72 | * formatting, github action ([cc5b724](https://github.com/chrisvander/zustand-computed/commit/cc5b7249f90d635a96cfa09143400a296d2d3750)) 73 | * modify example ([04089ff](https://github.com/chrisvander/zustand-computed/commit/04089ffe37dd2e630343f65ccad64aec532d289c)) 74 | * test ([7d3807f](https://github.com/chrisvander/zustand-computed/commit/7d3807fb21c9451c0d7f73a5dbaf9748ad39d01f)) 75 | * test and tweak slices pattern impl ([a50baf3](https://github.com/chrisvander/zustand-computed/commit/a50baf39b17ed8983039c6f76ed2bd8986cf4f72)) 76 | * type for slices ([0f022cf](https://github.com/chrisvander/zustand-computed/commit/0f022cf29d5bb430e694eb24c6f30c89867255ce)) 77 | * update tests for slices pattern, represents better use scenario ([50c9854](https://github.com/chrisvander/zustand-computed/commit/50c98549af8de02020622a0869e48b64650a18e6)) 78 | 79 | ## [1.3.7](https://github.com/chrisvander/zustand-computed/compare/v1.3.6...v1.3.7) (2023-11-06) 80 | 81 | 82 | ### Bug Fixes 83 | 84 | * changed optional param to rest parameter, fix type ([b42971d](https://github.com/chrisvander/zustand-computed/commit/b42971d30b3846d44b0b5487770f4a9efc75bdf2)) 85 | * updated setWithComputed by adding the third optional action parameter ([570cf6a](https://github.com/chrisvander/zustand-computed/commit/570cf6a7a2809fff1b4e4e0e6417c9086e08a8fa)) 86 | 87 | ## [1.3.6](https://github.com/chrisvander/zustand-computed/compare/v1.3.5...v1.3.6) (2023-09-15) 88 | 89 | 90 | ### Bug Fixes 91 | 92 | * enforce simple strict tsconfig ([d66f4a9](https://github.com/chrisvander/zustand-computed/commit/d66f4a924f5718ea88b3f9ad7af61ec35c6b3dce)) 93 | * types & method of comparing existing ([398d6f2](https://github.com/chrisvander/zustand-computed/commit/398d6f2a2bb2dc3d6dcc64cc3c1e6dca4f63d21f)) 94 | * Update zustand peerDependencies; Update example zustand. Fix example test ([e41b2c3](https://github.com/chrisvander/zustand-computed/commit/e41b2c32105da94bfff85705d4b708205d7390d0)) 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zustand-computed 2 | 3 | [![NPM Package][npm-img]][npm-url] 4 | [![Bundle Size][size-img]][size-url] 5 | [![Build Status][build-img]][build-url] 6 | [![Downloads][downloads-img]][downloads-url] 7 | [![Issues][issues-img]][issues-url] 8 | 9 | zustand-computed is a lightweight, TypeScript-friendly middleware for the state management system [Zustand](https://github.com/pmndrs/zustand). It's a simple layer which adds a transformation function after any state change in your store. 10 | 11 | ## Install 12 | 13 | ```bash 14 | # one of the following 15 | npm i zustand-computed 16 | pnpm i zustand-computed 17 | bun add zustand-computed 18 | yarn add zustand-computed 19 | ``` 20 | 21 | ## Usage 22 | 23 | The middleware layer takes in your store creation function and a compute function, which transforms your state into a computed state. It does not need to handle merging states. 24 | 25 | ```js 26 | import { createComputed } from "zustand-computed" 27 | 28 | const computed = createComputed((state) => ({ 29 | countSq: state.count ** 2, 30 | })) 31 | 32 | const useStore = create( 33 | computed( 34 | (set, get) => ({ 35 | count: 1, 36 | inc: () => set((state) => ({ count: state.count + 1 })), 37 | dec: () => set((state) => ({ count: state.count - 1 })), 38 | // get() function has access to ComputedStore 39 | square: () => set(() => ({ count: get().countSq })), 40 | root: () => set((state) => ({ count: Math.floor(Math.sqrt(state.count)) })), 41 | }) 42 | ) 43 | ) 44 | ``` 45 | 46 | With types, the previous example would look like this: 47 | 48 | ```ts 49 | import { createComputed } from "zustand-computed" 50 | 51 | type Store = { 52 | count: number 53 | inc: () => void 54 | dec: () => void 55 | } 56 | 57 | type ComputedStore = { 58 | countSq: number 59 | } 60 | 61 | const computed = createComputed((state: Store): ComputedStore => ({ 62 | countSq: state.count ** 2, 63 | })) 64 | 65 | const useStore = create()( 66 | computed( 67 | (set) => ({ 68 | count: 1, 69 | inc: () => set((state) => ({ count: state.count + 1 })), 70 | dec: () => set((state) => ({ count: state.count - 1 })), 71 | // get() function has access to ComputedStore 72 | square: () => set(() => ({ count: get().countSq })), 73 | root: () => set((state) => ({ count: Math.floor(Math.sqrt(state.count)) })), 74 | }) 75 | ) 76 | ) 77 | ``` 78 | 79 | The store can then be used as normal in a React component or via the Zustand API. 80 | 81 | ```tsx 82 | function Counter() { 83 | const { count, countSq, inc, dec } = useStore() 84 | return ( 85 |
86 | {count} 87 |
88 | {countSq} 89 |
90 | 91 | 92 |
93 | ) 94 | } 95 | ``` 96 | 97 | ## With Middleware 98 | 99 | Here's an example with the Immer middleware. 100 | 101 | ```ts 102 | const computed = createComputed((state: Store) => { /* ... */ }) 103 | const useStore = create()( 104 | devtools( 105 | immer( 106 | computed( 107 | (set) => ({ 108 | count: 1, 109 | inc: () => 110 | set((state) => { 111 | // example with Immer middleware 112 | state.count += 1 113 | }), 114 | dec: () => set((state) => ({ count: state.count - 1 })), 115 | }) 116 | ) 117 | ) 118 | ) 119 | ) 120 | ``` 121 | 122 | ## Skip Computation 123 | 124 | By default, your compute function runs every time the store changes. If you use slices, it will only run inside of the particular slice that changes. For simple functions, this may not make a big difference. If you want to skip computation, you've got two options: a `keys` array, or a `shouldRecompute` function. Both can be passed in the opts, like below: 125 | 126 | ```ts 127 | // only recomputes when "count" changes 128 | const computed = createComputed((state: Store) => { /* ... */ }, { keys: ["count"] }) 129 | // only recomputes when the current state's count does not equal the next state's count (same as above, but more explicit) 130 | const computedWithShouldRecomputeFn = createComputed((state: Store) => { /* ... */ }, { shouldRecompute: (state, nextState) => { 131 | return state.count !== nextState.count 132 | } }) 133 | const useStore = create( 134 | computed( 135 | (set) => ({ 136 | count: 1, 137 | inc: () => set((state) => ({ count: state.count + 1 })), 138 | dec: () => set((state) => ({ count: state.count - 1 })), 139 | }) 140 | ) 141 | ) 142 | ``` 143 | 144 | # Memoization 145 | 146 | `zustand-computed` ensures that, if a newly-computed value is equal to the previous value, it will prevent the reference from changing so your components don't have unnecessary re-renders. You can customize this behavior with an optional `equalityFn`, such as [fast-deep-equal](https://github.com/epoberezkin/fast-deep-equal). By default, it uses `zustand/shallow` to compare values, but if you have a deeply nested state you may want to reach for something more powerful. 147 | 148 | [build-img]: https://github.com/chrisvander/zustand-computed/actions/workflows/ci.yml/badge.svg 149 | [build-url]: https://github.com/chrisvander/zustand-computed/actions/workflows/ci.yml 150 | [size-img]: https://img.shields.io/bundlephobia/minzip/zustand-computed 151 | [size-url]: https://bundlephobia.com/package/zustand-computed@1.4.2 152 | [downloads-img]: https://img.shields.io/npm/dt/zustand-computed 153 | [downloads-url]: https://www.npmtrends.com/zustand-computed 154 | [npm-img]: https://img.shields.io/npm/v/zustand-computed 155 | [npm-url]: https://www.npmjs.com/package/zustand-computed 156 | [issues-img]: https://img.shields.io/github/issues/chrisvander/zustand-computed 157 | [issues-url]: https://github.com/chrisvander/chrisvander/zustand-computed/issues 158 | -------------------------------------------------------------------------------- /src/computed.ts: -------------------------------------------------------------------------------- 1 | import type { Mutate, StateCreator, StoreApi, StoreMutatorIdentifier } from "zustand" 2 | import { shallow } from "zustand/shallow" 3 | 4 | /** 5 | * Options for when and how your compute function is called. 6 | */ 7 | export type ComputedStateOpts = ( 8 | | { 9 | /** 10 | * An explicit list of keys to track for recomputation. By default, 11 | * `zustand-computed` will run your compute function on any change. 12 | * This lets you filter those keys out. It's better to use the 13 | * `compareFn` to be more explicit about how comparison is determined. 14 | */ 15 | keys?: (keyof T)[] 16 | } 17 | | { 18 | /** 19 | * Custom comparison function to determine whether to recompute. 20 | * Receives the previous and next store state, should return true if 21 | * compute should run, false to skip recomputation. This function 22 | * should be *fast* - it determines whether or not you need to 23 | * recompute. 24 | */ 25 | shouldRecompute?: (state: T, nextState: T | Partial) => boolean 26 | } 27 | ) & { 28 | /** 29 | * @deprecated removed proxy; this does nothing and will be removed. 30 | */ 31 | disableProxy?: boolean 32 | /** 33 | * Custom equality function for comparing computed values. By default, we use 34 | * `zustand/shallow` to compare each key in your store against the newly- 35 | * computed values. This is likely the desired method of comparison. 36 | * 37 | * The motivation for this function is to ensure that, in the case your 38 | * computed function returns a value that is identical structurally, it 39 | * should not cause a re-render despite the reference being different. 40 | * 41 | * You can disable comparison, so that the most recent result of the 42 | * compute function always triggers downstream re-renders, by simply 43 | * returning false. 44 | */ 45 | equalityFn?: (a: Y, b: Y) => boolean 46 | } 47 | 48 | export type ComputedStateCreator = ( 49 | compute: (state: T) => A, 50 | opts?: ComputedStateOpts, 51 | ) => < 52 | Mps extends [StoreMutatorIdentifier, unknown][] = [], 53 | Mcs extends [StoreMutatorIdentifier, unknown][] = [], 54 | U = T, 55 | >( 56 | f: StateCreator, 57 | ) => StateCreator 58 | 59 | type Cast = T extends U ? T : U 60 | type Write = Omit & U 61 | type StoreCompute = S extends { 62 | getState: () => infer T 63 | } 64 | ? Omit, "setState"> 65 | : never 66 | type WithCompute = Write> 67 | 68 | declare module "zustand/vanilla" { 69 | interface StoreMutators { 70 | "chrisvander/zustand-computed": WithCompute, A> 71 | } 72 | } 73 | 74 | type ComputedStateImpl = ( 75 | compute: (state: T) => A, 76 | opts?: ComputedStateOpts, 77 | ) => (f: StateCreator) => StateCreator 78 | 79 | const computedImpl: ComputedStateImpl = (compute, opts) => (f) => { 80 | type T = ReturnType 81 | type A = ReturnType 82 | 83 | const optsKeys = !opts || !("keys" in opts) || opts.keys == null ? undefined : opts.keys 84 | const keysSet = optsKeys ? new Set(optsKeys as string[]) : undefined 85 | 86 | function defaultShouldRecomputeFn(_: T, nextState: T | Partial): boolean { 87 | if (!keysSet || nextState == null) return true 88 | return Object.keys(nextState).some((k) => keysSet.has(k)) 89 | } 90 | 91 | const shouldRecomputeFn = 92 | opts && "shouldRecompute" in opts ? (opts.shouldRecompute ?? defaultShouldRecomputeFn) : defaultShouldRecomputeFn 93 | 94 | // Set of keys that have been accessed in any compute call. 95 | return (set, get, api) => { 96 | const equalityFn = opts?.equalityFn ?? shallow 97 | 98 | function computeAndMerge(state: T | (T & A)): T & A { 99 | // Calculate the new computed state. 100 | const computedState = compute(state) 101 | 102 | // If part of the computed state did not change according to the equalityFn, 103 | // then delete that key from the newly calculated computed state. 104 | for (const k of Object.keys(computedState) as (keyof A)[]) { 105 | if (k in state && equalityFn(computedState[k], (state as T & A)[k])) { 106 | delete computedState[k] 107 | } 108 | } 109 | 110 | return Object.assign(state, computedState) 111 | } 112 | 113 | const _api = api as Mutate, [["chrisvander/zustand-computed", A]]> 114 | 115 | /** 116 | * Higher level function to handle compute & compare overhead. 117 | */ 118 | function setState(partial: T | Partial | ((state: T) => T | Partial), replace?: false): void 119 | function setState(state: T | ((state: T) => T), replace: true): void 120 | function setState(arg: T | Partial | ((state: T) => T | Partial), replace?: boolean): void { 121 | if (replace === false || replace == null) { 122 | // Merge the partial state with the current state. 123 | set((state) => { 124 | const newState = typeof arg === "function" ? arg(state) : arg 125 | if (!shouldRecomputeFn(state, newState)) return newState 126 | return computeAndMerge(Object.assign(state, newState)) 127 | }, replace) 128 | return 129 | } 130 | 131 | set((state) => { 132 | const newArg = arg as T | ((state: T) => T) 133 | const newState: T = typeof newArg === "function" ? newArg(state) : newArg 134 | if (!shouldRecomputeFn(state, newState)) return newState 135 | return computeAndMerge(newState) 136 | }, replace) 137 | } 138 | 139 | _api.setState = setState 140 | const st = f(setState, get, _api) 141 | return Object.assign({}, st, compute(st)) 142 | } 143 | } 144 | 145 | export const createComputed = computedImpl as unknown as ComputedStateCreator 146 | -------------------------------------------------------------------------------- /src/computed.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, mock, test } from "bun:test" 2 | import { type StateCreator, create } from "zustand" 3 | import { immer } from "zustand/middleware/immer" 4 | import { type ComputedStateOpts, createComputed } from "./computed" 5 | 6 | type Store = { 7 | count: number 8 | x: number 9 | y: number 10 | inc: () => void 11 | dec: () => void 12 | } 13 | 14 | type ComputedStore = { 15 | countSq: number 16 | nestedResult: { 17 | stringified: string 18 | } 19 | } 20 | 21 | function computeState(state: Store): ComputedStore { 22 | const nestedResult = { 23 | stringified: JSON.stringify(state.count), 24 | } 25 | 26 | return { 27 | countSq: state.count ** 2, 28 | nestedResult, 29 | } 30 | } 31 | 32 | describe("single store", () => { 33 | const computeStateMock = mock(computeState) 34 | const makeStore = (opts?: ComputedStateOpts) => { 35 | const computed = createComputed(computeStateMock, opts) 36 | return create( 37 | computed((set) => ({ 38 | count: 1, 39 | x: 1, 40 | y: 1, 41 | inc: () => set((state) => ({ count: state.count + 1 })), 42 | dec: () => set((state) => ({ count: state.count - 1 })), 43 | })), 44 | ) 45 | } 46 | 47 | beforeEach(() => { 48 | computeStateMock.mockClear() 49 | }) 50 | 51 | test("computed works on simple counter example", () => { 52 | const useStore = makeStore() 53 | // note: this function should have been called once on store creation 54 | expect(computeStateMock).toHaveBeenCalledTimes(1) 55 | expect(useStore.getState().count).toEqual(1) 56 | expect(useStore.getState().countSq).toEqual(1) 57 | useStore.getState().inc() 58 | expect(useStore.getState().count).toEqual(2) 59 | expect(useStore.getState().countSq).toEqual(4) 60 | useStore.getState().dec() 61 | expect(useStore.getState().count).toEqual(1) 62 | expect(useStore.getState().countSq).toEqual(1) 63 | useStore.setState({ count: 4 }) 64 | expect(useStore.getState().countSq).toEqual(16) 65 | expect(computeStateMock).toHaveBeenCalledTimes(4) 66 | }) 67 | 68 | test("computed does not modify object ref even after change", () => { 69 | const useStore = makeStore() 70 | useStore.setState({ count: 4 }) 71 | expect(useStore.getState().count).toEqual(4) 72 | const obj = useStore.getState().nestedResult 73 | useStore.setState({ count: 4 }) 74 | const toCompare = useStore.getState().nestedResult 75 | expect(obj).toEqual(toCompare) 76 | }) 77 | 78 | test("any store change, by default, triggers compute function", () => { 79 | const useStore = makeStore() 80 | expect(computeStateMock).toHaveBeenCalledTimes(1) 81 | useStore.setState({ x: 2 }) 82 | expect(computeStateMock).toHaveBeenCalledTimes(2) 83 | useStore.setState({ x: 3 }) 84 | expect(computeStateMock).toHaveBeenCalledTimes(3) 85 | useStore.setState({ y: 2 }) 86 | expect(computeStateMock).toHaveBeenCalledTimes(4) 87 | }) 88 | 89 | test("modifying variables x and y do not trigger compute function when `keys` are specified", () => { 90 | const useStore = makeStore({ keys: ["count"] }) 91 | expect(computeStateMock).toHaveBeenCalledTimes(1) 92 | useStore.setState({ x: 2 }) 93 | expect(computeStateMock).toHaveBeenCalledTimes(1) 94 | useStore.setState({ x: 3 }) 95 | expect(computeStateMock).toHaveBeenCalledTimes(1) 96 | useStore.setState({ y: 2 }) 97 | expect(computeStateMock).toHaveBeenCalledTimes(1) 98 | }) 99 | 100 | test("modifying variables x and y do not trigger compute function when `shouldRecompute` is defined", () => { 101 | const useStore = makeStore({ shouldRecompute: (_, nextState) => "count" in nextState }) 102 | expect(computeStateMock).toHaveBeenCalledTimes(1) 103 | useStore.setState({ x: 2 }) 104 | expect(computeStateMock).toHaveBeenCalledTimes(1) 105 | useStore.setState({ x: 3 }) 106 | expect(computeStateMock).toHaveBeenCalledTimes(1) 107 | useStore.setState({ y: 2 }) 108 | expect(computeStateMock).toHaveBeenCalledTimes(1) 109 | }) 110 | 111 | test("computed does not update when a custom key selector is given", () => { 112 | const useStore = makeStore({ keys: ["x", "y"] }) 113 | // because we only care about x and y, the compute function should not be called when count changes 114 | expect(computeStateMock).toHaveBeenCalledTimes(1) 115 | expect(useStore.getState().count).toEqual(1) 116 | expect(useStore.getState().countSq).toEqual(1) 117 | useStore.getState().inc() 118 | expect(useStore.getState().count).toEqual(2) 119 | expect(useStore.getState().countSq).toEqual(1) 120 | useStore.getState().dec() 121 | expect(useStore.getState().count).toEqual(1) 122 | expect(useStore.getState().countSq).toEqual(1) 123 | expect(computeStateMock).toHaveBeenCalledTimes(1) 124 | }) 125 | 126 | test("disabling proxy causes compute to run every time", () => { 127 | const useStore = makeStore({ disableProxy: true }) 128 | expect(computeStateMock).toHaveBeenCalledTimes(1) 129 | useStore.setState({ count: 4 }) 130 | useStore.setState({ x: 2 }) 131 | useStore.setState({ y: 3 }) 132 | expect(useStore.getState().count).toEqual(4) 133 | expect(useStore.getState().countSq).toEqual(16) 134 | expect(computeStateMock).toHaveBeenCalledTimes(4) 135 | }) 136 | }) 137 | 138 | type CountSlice = Pick 139 | type XYSlice = Pick 140 | function computeSlice(state: CountSlice): ComputedStore { 141 | const nestedResult = { 142 | stringified: JSON.stringify(state.count), 143 | } 144 | 145 | return { 146 | countSq: state.count ** 2, 147 | nestedResult, 148 | } 149 | } 150 | 151 | describe("slices pattern", () => { 152 | const computeSliceMock = mock(computeSlice) 153 | const computed = createComputed(computeSliceMock) 154 | const makeStore = () => { 155 | const createCountSlice: StateCreator< 156 | Store, 157 | [], 158 | [["chrisvander/zustand-computed", ComputedStore]], 159 | CountSlice & ComputedStore 160 | > = computed((set) => ({ 161 | count: 1, 162 | dec: () => set((state) => ({ count: state.count - 1 })), 163 | })) 164 | 165 | const createXySlice: StateCreator = (set) => ({ 166 | x: 1, 167 | y: 1, 168 | // this should not trigger compute function 169 | inc: () => set((state) => ({ count: state.count + 2 })), 170 | }) 171 | 172 | return create()((...a) => ({ 173 | ...createCountSlice(...a), 174 | ...createXySlice(...a), 175 | })) 176 | } 177 | 178 | beforeEach(() => { 179 | computeSliceMock.mockClear() 180 | }) 181 | 182 | test("computed works on slices pattern example", () => { 183 | const useStore = makeStore() 184 | expect(computeSliceMock).toHaveBeenCalledTimes(1) 185 | expect(useStore.getState().count).toEqual(1) 186 | expect(useStore.getState().countSq).toEqual(1) 187 | useStore.getState().inc() 188 | expect(useStore.getState().count).toEqual(3) 189 | expect(useStore.getState().countSq).toEqual(1) 190 | expect(computeSliceMock).toHaveBeenCalledTimes(1) 191 | useStore.getState().dec() 192 | expect(useStore.getState().count).toEqual(2) 193 | expect(useStore.getState().countSq).toEqual(4) 194 | expect(computeSliceMock).toHaveBeenCalledTimes(2) 195 | useStore.setState({ count: 4 }) 196 | expect(useStore.getState().countSq).toEqual(16) 197 | expect(computeSliceMock).toHaveBeenCalledTimes(3) 198 | }) 199 | }) 200 | 201 | describe("immer middleware functions without throwing", () => { 202 | type Store = { 203 | count: number 204 | inc: () => void 205 | dec: () => void 206 | } 207 | 208 | type ComputedStore = { 209 | countSq: number 210 | } 211 | 212 | const computed = createComputed( 213 | (state: Store): ComputedStore => ({ 214 | countSq: state.count ** 2, 215 | }), 216 | { keys: ["count"] }, 217 | ) 218 | 219 | const useStore = create()( 220 | immer( 221 | computed((set) => ({ 222 | count: 1, 223 | inc: () => 224 | set((state) => { 225 | // example with Immer middleware 226 | state.count += 1 227 | }), 228 | dec: () => set((state) => ({ count: state.count - 1 })), 229 | })), 230 | ), 231 | ) 232 | 233 | expect(() => useStore.getState().inc()).not.toThrow() 234 | expect(useStore.getState().count).toEqual(2) 235 | expect(useStore.getState().countSq).toEqual(4) 236 | expect(() => useStore.getState().dec()).not.toThrow() 237 | expect(useStore.getState().count).toEqual(1) 238 | expect(useStore.getState().countSq).toEqual(1) 239 | }) 240 | --------------------------------------------------------------------------------