├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── .size-snapshot.json ├── LICENSE ├── babel.config.js ├── eslintrc.back ├── package.json ├── readme.md ├── rollup.config.js ├── src └── index.ts ├── tests └── computed.test.tsx ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | Thumbs.db 5 | ehthumbs.db 6 | Desktop.ini 7 | $RECYCLE.BIN/ 8 | .DS_Store 9 | .vscode 10 | .docz/ 11 | package-lock.json 12 | coverage/ 13 | .idea 14 | .rpt2_cache/ 15 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn run lint-staged 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /.size-snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "index.js": { 3 | "bundled": 884, 4 | "minified": 437, 5 | "gzipped": 278, 6 | "treeshaked": { 7 | "rollup": { 8 | "code": 0, 9 | "import_statements": 0 10 | }, 11 | "webpack": { 12 | "code": 951 13 | } 14 | } 15 | }, 16 | "index.cjs.js": { 17 | "bundled": 968, 18 | "minified": 570, 19 | "gzipped": 302 20 | }, 21 | "index.iife.js": { 22 | "bundled": 1094, 23 | "minified": 446, 24 | "gzipped": 265 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Caleb Larsen 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 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api, targets) => { 2 | // https://babeljs.io/docs/en/config-files#config-function-api 3 | const isTestEnv = api.env('test') 4 | 5 | return { 6 | babelrc: false, 7 | ignore: ['./node_modules'], 8 | presets: [ 9 | [ 10 | '@babel/preset-env', 11 | { 12 | loose: true, 13 | modules: isTestEnv ? 'commonjs' : false, 14 | targets: isTestEnv ? { node: 'current' } : targets, 15 | }, 16 | ], 17 | ], 18 | plugins: [ 19 | '@babel/plugin-transform-react-jsx', 20 | ['@babel/plugin-transform-typescript', { isTSX: true }], 21 | ], 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /eslintrc.back: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "shared-node-browser": true, 5 | "node": true, 6 | "es6": true 7 | }, 8 | "extends": [ 9 | "prettier", 10 | "prettier/react", 11 | "prettier/@typescript-eslint", 12 | "plugin:prettier/recommended", 13 | "plugin:react-hooks/recommended", 14 | "plugin:import/errors", 15 | "plugin:import/warnings" 16 | ], 17 | "plugins": ["@typescript-eslint", "react", "prettier", "react-hooks", "import", "jest"], 18 | "parser": "@typescript-eslint/parser", 19 | "parserOptions": { 20 | "ecmaVersion": 2018, 21 | "sourceType": "module", 22 | "ecmaFeatures": { 23 | "jsx": true 24 | }, 25 | "rules": { 26 | "curly": ["warn", "multi-line", "consistent"], 27 | "no-console": "off", 28 | "no-empty-pattern": "warn", 29 | "no-duplicate-imports": "error", 30 | "import/no-unresolved": ["error", { "commonjs": true, "amd": true }], 31 | "import/export": "error", 32 | "import/named": "off", 33 | "import/namespace": "off", 34 | "import/default": "off", 35 | "@typescript-eslint/explicit-module-boundary-types": "off", 36 | "no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], 37 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], 38 | "@typescript-eslint/no-use-before-define": "off", 39 | "@typescript-eslint/no-empty-function": "off", 40 | "@typescript-eslint/no-empty-interface": "off", 41 | "@typescript-eslint/no-explicit-any": "off", 42 | "jest/consistent-test-it": ["error", { "fn": "it", "withinDescribe": "it" }] 43 | } 44 | }, 45 | "settings": { 46 | "react": { 47 | "version": "detect" 48 | }, 49 | "import/extensions": [".js", ".jsx", ".ts", ".tsx"], 50 | "import/parsers": { 51 | "@typescript-eslint/parser": [".js", ".jsx", ".ts", ".tsx"] 52 | }, 53 | "import/resolver": { 54 | "node": { 55 | "extensions": [".js", ".jsx", ".ts", ".tsx", ".json"], 56 | "paths": ["src"] 57 | }, 58 | "alias": { 59 | "extensions": [".js", ".jsx", ".ts", ".tsx", ".json"], 60 | "map": [["zustand", "./src/index.ts"]] 61 | } 62 | } 63 | }, 64 | "overrides": [ 65 | { 66 | "files": ["src"], 67 | "parserOptions": { 68 | "project": "./tsconfig.json" 69 | } 70 | } 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zustand-middleware-computed-state", 3 | "private": true, 4 | "version": "0.1.2", 5 | "description": "Computed state middleware for Zustand", 6 | "main": "index.cjs.js", 7 | "module": "index.js", 8 | "types": "index.d.ts", 9 | "files": [ 10 | "**" 11 | ], 12 | "sideEffects": false, 13 | "scripts": { 14 | "prebuild": "rimraf dist", 15 | "build": "rollup -c --bundleConfigAsCjs", 16 | "postbuild": "yarn copy", 17 | "eslint": "eslint --fix 'src/**/*.{js,ts,jsx,tsx}'", 18 | "eslint-examples": "eslint --fix 'examples/src/**/*.{js,ts,jsx,tsx}'", 19 | "eslint:ci": "eslint '{src,examples/src}/**/*.{js,ts,jsx,tsx}'", 20 | "prepare": "yarn build", 21 | "postinstall": "husky install", 22 | "pretest": "tsc --noEmit", 23 | "test": "jest", 24 | "test:dev": "jest --watch --no-coverage", 25 | "test:coverage:watch": "jest --watch", 26 | "copy": "mv dist/src/* dist && rm -rf dist/{src,tests} && copyfiles -f package.json readme.md LICENSE dist && json -I -f dist/package.json -e \"this.private=false; this.devDependencies=undefined; this.optionalDependencies=undefined; this.scripts=undefined; this.husky=undefined; this.prettier=undefined; this.jest=undefined; this['lint-staged']=undefined;\"" 27 | }, 28 | "prettier": { 29 | "semi": false, 30 | "trailingComma": "es5", 31 | "singleQuote": true, 32 | "jsxBracketSameLine": true, 33 | "tabWidth": 2, 34 | "printWidth": 80 35 | }, 36 | "lint-staged": { 37 | "*.{js,ts,tsx}": [ 38 | "prettier --write" 39 | ] 40 | }, 41 | "repository": { 42 | "type": "git", 43 | "url": "git+https://github.com/cmlarsen/zustand-middleware-computed-state.git" 44 | }, 45 | "keywords": [ 46 | "react", 47 | "state", 48 | "manager", 49 | "management", 50 | "redux", 51 | "store", 52 | "zustand", 53 | "computed", 54 | "derived", 55 | "selector", 56 | "middleware" 57 | ], 58 | "author": "Caleb Larsen", 59 | "license": "MIT", 60 | "bugs": { 61 | "url": "https://github.com/cmlarsen/zustand-middleware-computed-state/issues" 62 | }, 63 | "homepage": "https://github.com/cmlarsen/zustand-middleware-computed-state", 64 | "jest": { 65 | "testRegex": "test.(js|ts|tsx)$", 66 | "coverageDirectory": "./coverage/", 67 | "collectCoverage": true, 68 | "coverageReporters": [ 69 | "json", 70 | "html", 71 | "text", 72 | "text-summary" 73 | ], 74 | "collectCoverageFrom": [ 75 | "src/**/*.{js,ts,tsx}", 76 | "tests/**/*.{js,ts,tsx}" 77 | ], 78 | "modulePathIgnorePatterns": [ 79 | "dist/" 80 | ] 81 | }, 82 | "devDependencies": { 83 | "@babel/core": "^7.20.12", 84 | "@babel/plugin-external-helpers": "^7.18.6", 85 | "@babel/plugin-transform-react-jsx": "^7.20.13", 86 | "@babel/plugin-transform-runtime": "^7.19.6", 87 | "@babel/plugin-transform-typescript": "^7.20.13", 88 | "@babel/preset-env": "^7.20.2", 89 | "@rollup/plugin-babel": "^6.0.3", 90 | "@rollup/plugin-node-resolve": "^15.0.1", 91 | "@rollup/plugin-typescript": "^11.0.0", 92 | "@testing-library/react": "^13.4.0", 93 | "@types/jest": "^29.4.0", 94 | "@types/react": "^18.0.27", 95 | "@types/react-dom": "^18.0.10", 96 | "@typescript-eslint/eslint-plugin": "^5.50.0", 97 | "@typescript-eslint/parser": "^5.50.0", 98 | "copyfiles": "^2.4.1", 99 | "eslint": "^8.33.0", 100 | "eslint-config-prettier": "^8.6.0", 101 | "eslint-import-resolver-alias": "^1.1.2", 102 | "eslint-plugin-import": "^2.27.5", 103 | "eslint-plugin-jest": "^27.2.1", 104 | "eslint-plugin-prettier": "^4.2.1", 105 | "eslint-plugin-react": "^7.32.2", 106 | "eslint-plugin-react-hooks": "^4.6.0", 107 | "husky": "^8.0.3", 108 | "jest": "^29.4.1", 109 | "json": "^11.0.0", 110 | "lint-staged": "^13.1.0", 111 | "prettier": "^2.8.3", 112 | "react": "^18.2.0", 113 | "react-dom": "^18.2.0", 114 | "rimraf": "^4.1.2", 115 | "rollup": "^3.12.0", 116 | "rollup-plugin-size-snapshot": "^0.12.0", 117 | "typescript": "^4.9.5", 118 | "zustand": "^4.3.2" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Zustand Computed State Middleware 2 | 3 | This is a dead simple middleware for adding computed state to state management library [Zustand](https://github.com/pmndrs/zustand). 4 | 5 | ``` 6 | npm install zustand-middleware-computed-state 7 | -- or -- 8 | yarn add zustand-middleware-computed-state 9 | ``` 10 | 11 | The computed values are defined as a function passed as the second argument passed into the `computed(store, computedStore)` middleware. The resulting values will be merged into the store and accessible just like any other bit of Zustand state. 12 | 13 | Since this computed state is updated on every state change, these should be kept light. 14 | 15 | ```javascript 16 | import create from 'zustand' 17 | import { computed } from 'zustand-middleware-computed-state' 18 | 19 | const useState = create(computed(store, computedStore)) 20 | ``` 21 | 22 | ## Example 23 | 24 | ```javascript 25 | import create from 'zustand' 26 | import { computed } from 'zustand-middleware-computed-state' 27 | 28 | const useStore = create( 29 | computed( 30 | (set) => ({ 31 | count: 0, 32 | inc: () => set((state) => ({ count: state.count + 1 })), 33 | }), 34 | (state) => { 35 | function isEnough() { 36 | if (state.count > 100) { 37 | return 'Is enough' 38 | } else { 39 | return 'Is not enough' 40 | } 41 | } 42 | 43 | return { 44 | computedCount: state.count + 10, 45 | isEnough: isEnough(), 46 | } 47 | } 48 | ) 49 | ) 50 | 51 | function Counter() { 52 | const { count, computedCount, isEnough, inc } = useStore() 53 | 54 | return ( 55 |
56 | 57 |
count: {count}
{/* output: 1*/} 58 |
computedCount: {computedCount}
{/* output: 11*/} 59 |
isEnough: {isEnough}
{/* output: "Is not enough*/} 60 |
61 | ) 62 | } 63 | ``` 64 | 65 | ## TypeScript 66 | code below demonstrates how you can use it with Typescript: 67 | ```typescript 68 | import { create } from "zustand"; 69 | import { computed } from "zustand-middleware-computed-state"; 70 | 71 | type ComputedStore = { 72 | sum: number; 73 | }; 74 | 75 | type Store = { 76 | x: number; 77 | y: number; 78 | incX: (by: number) => void 79 | incY: (by: number) => void 80 | }; 81 | 82 | type SetType = ( 83 | partial: 84 | | Store 85 | | Partial 86 | | ((state: Store) => Store | Partial), 87 | replace?: boolean | undefined 88 | ) => void; 89 | 90 | type CombinedStore = State & ComputedStore; 91 | 92 | function computedState(state: Store): ComputedStore { 93 | return { 94 | sum: state.x + state.y, 95 | }; 96 | } 97 | 98 | const useSampleStore = create( 99 | computed( 100 | (set: SetType) => ({ 101 | x: 0, 102 | y: 0, 103 | incX: (by) => set(store => ({x: state.x + by})), 104 | incY: (by) => set(store => ({y: state.y + by})) 105 | }), 106 | computedState 107 | ) 108 | ); 109 | 110 | export default useSampleStore; 111 | 112 | ``` 113 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import babel from '@rollup/plugin-babel' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import typescript from '@rollup/plugin-typescript' 5 | import { sizeSnapshot } from 'rollup-plugin-size-snapshot' 6 | 7 | const createBabelConfig = require('./babel.config') 8 | 9 | const { root } = path.parse(process.cwd()) 10 | const external = (id) => !id.startsWith('.') && !id.startsWith(root) 11 | const extensions = ['.js', '.ts', '.tsx'] 12 | const getBabelOptions = (targets) => { 13 | const config = createBabelConfig({ env: (env) => env === 'build' }, targets) 14 | if (targets.ie) { 15 | config.plugins = [ 16 | ...config.plugins, 17 | '@babel/plugin-transform-regenerator', 18 | ['@babel/plugin-transform-runtime', { helpers: true, regenerator: true }], 19 | ] 20 | config.babelHelpers = 'runtime' 21 | } 22 | return { 23 | ...config, 24 | extensions, 25 | } 26 | } 27 | 28 | function createDeclarationConfig(input, output) { 29 | return { 30 | input, 31 | output: { 32 | dir: output, 33 | }, 34 | external, 35 | plugins: [typescript({ declaration: true, outDir: output })], 36 | } 37 | } 38 | 39 | function createESMConfig(input, output) { 40 | return { 41 | input, 42 | output: { file: output, format: 'esm' }, 43 | external, 44 | plugins: [ 45 | resolve({ extensions }), 46 | typescript(), 47 | babel(getBabelOptions({ node: 8 })), 48 | sizeSnapshot(), 49 | ], 50 | } 51 | } 52 | 53 | function createCommonJSConfig(input, output) { 54 | return { 55 | input, 56 | output: { file: output, format: 'cjs', exports: 'named' }, 57 | external, 58 | plugins: [ 59 | resolve({ extensions }), 60 | typescript(), 61 | babel(getBabelOptions({ ie: 11 })), 62 | sizeSnapshot(), 63 | ], 64 | } 65 | } 66 | 67 | function createIIFEConfig(input, output, globalName) { 68 | return { 69 | input, 70 | output: { 71 | file: output, 72 | format: 'iife', 73 | exports: 'named', 74 | name: globalName, 75 | globals: { 76 | react: 'React', 77 | '@babel/runtime/regenerator': 'regeneratorRuntime', 78 | }, 79 | }, 80 | external, 81 | plugins: [ 82 | resolve({ extensions }), 83 | typescript(), 84 | babel(getBabelOptions({ ie: 11 })), 85 | sizeSnapshot(), 86 | ], 87 | } 88 | } 89 | 90 | export default [ 91 | createDeclarationConfig('src/index.ts', 'dist'), 92 | createESMConfig('src/index.ts', 'dist/index.js'), 93 | createCommonJSConfig('src/index.ts', 'dist/index.cjs.js'), 94 | createIIFEConfig( 95 | 'src/index.ts', 96 | 'dist/index.iife.js', 97 | 'zustandMiddlewareComputedState' 98 | ), 99 | ] 100 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StateCreator, 3 | StoreApi, 4 | } from 'zustand'; 5 | 6 | export declare type ComputedState = (state: S) => S 7 | 8 | export const computed = 9 | (create: StateCreator, compute: (state: S) => C) => 10 | (set: StoreApi['setState'], get: StoreApi['getState'], api: StoreApi): S & C => { 11 | const setWithComputed: StoreApi['setState'] = (update, replace) => { 12 | set((state) => { 13 | const updated = typeof update === 'function' ? (update as (state: S) => Partial | S)(state) : update; 14 | const computedState = compute({ ...state, ...updated } as S); 15 | return { ...updated, ...computedState }; 16 | }, replace); 17 | }; 18 | api.setState = setWithComputed; 19 | const state = create(setWithComputed, get, api); 20 | return { ...state, ...compute(state) }; 21 | }; 22 | -------------------------------------------------------------------------------- /tests/computed.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { cleanup, render } from '@testing-library/react' 3 | import { create } from 'zustand' 4 | import { devtools } from 'zustand/middleware' 5 | 6 | import { computed } from '../src' 7 | 8 | const consoleError = console.error 9 | afterEach(() => { 10 | cleanup() 11 | console.error = consoleError 12 | }) 13 | 14 | interface Store { 15 | count: number; 16 | inc: () => void; 17 | } 18 | 19 | interface ComputedStore { 20 | computedCount: number; 21 | } 22 | 23 | it('returns initial computed state', async () => { 24 | const useStore = create( 25 | computed( 26 | (set) => ({ 27 | count: 0, 28 | inc: () => set((state: any) => ({ count: state.count + 1 })), 29 | }), 30 | (state) => { 31 | return { 32 | computedCount: state.count + 10, 33 | } 34 | } 35 | ) 36 | ) 37 | 38 | function Counter() { 39 | const { count, computedCount } = useStore() 40 | 41 | return ( 42 |
43 |
count: {count}
44 |
computedCount: {computedCount}
45 |
46 | ) 47 | } 48 | 49 | const { findByText } = render() 50 | 51 | await findByText('count: 0') 52 | await findByText('computedCount: 10') 53 | }) 54 | it('computed state updates when the state updates', async () => { 55 | const useStore = create( 56 | computed( 57 | (set) => ({ 58 | count: 0, 59 | inc: () => set((state) => ({ count: state.count + 1 })), 60 | }), 61 | (state) => { 62 | return { 63 | computedCount: state.count + 10, 64 | } 65 | } 66 | ) 67 | ) 68 | 69 | function Counter() { 70 | const { count, computedCount, inc } = useStore() 71 | React.useEffect(inc, [inc]) 72 | return ( 73 |
74 |
count: {count}
75 |
computedCount: {computedCount}
76 |
77 | ) 78 | } 79 | 80 | const { findByText } = render() 81 | 82 | await findByText('count: 1') 83 | await findByText('computedCount: 11') 84 | }) 85 | 86 | it('computed state updates when state updated via API', async () => { 87 | const useStore = create( 88 | computed( 89 | (set) => ({ 90 | count: 0, 91 | inc: () => set((state) => ({ count: state.count + 1 })), 92 | }), 93 | (state) => { 94 | return { 95 | computedCount: state.count + 10, 96 | } 97 | } 98 | ) 99 | ) 100 | useStore.setState({ count: 1 }) 101 | 102 | function Counter() { 103 | const { count, computedCount } = useStore() 104 | 105 | return ( 106 |
107 |
count: {count}
108 |
computedCount: {computedCount}
109 |
110 | ) 111 | } 112 | 113 | const { findByText } = render() 114 | 115 | await findByText('count: 1') 116 | await findByText('computedCount: 11') 117 | }) 118 | 119 | it('computed state updates when object shorthand', async () => { 120 | const useStore = create( 121 | computed( 122 | (set) => ({ 123 | count: 0, 124 | inc: () => set({ count: 1 }), 125 | }), 126 | (state) => { 127 | return { 128 | computedCount: state.count + 10, 129 | } 130 | } 131 | ) 132 | ) 133 | 134 | function Counter() { 135 | const { count, computedCount, inc } = useStore() 136 | React.useEffect(inc, [inc]) 137 | return ( 138 |
139 |
count: {count}
140 |
computedCount: {computedCount}
141 |
142 | ) 143 | } 144 | 145 | const { findByText } = render() 146 | 147 | await findByText('count: 1') 148 | await findByText('computedCount: 11') 149 | }) 150 | 151 | it('computed state when composed with devtools middleware', async () => { 152 | const useStore = create( 153 | devtools( 154 | computed( 155 | (set) => ({ 156 | count: 0, 157 | inc: () => set((state) => ({ count: state.count + 1 })), 158 | }), 159 | (state) => { 160 | return { 161 | computedCount: state.count + 10, 162 | } 163 | } 164 | ) 165 | ) 166 | ) 167 | 168 | function Counter() { 169 | const { count, computedCount, inc } = useStore() 170 | React.useEffect(inc, [inc]) 171 | return ( 172 |
173 |
count: {count}
174 |
computedCount: {computedCount}
175 |
176 | ) 177 | } 178 | 179 | const { findByText } = render() 180 | 181 | await findByText('count: 1') 182 | await findByText('computedCount: 11') 183 | }) 184 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "strict": true, 5 | "jsx": "preserve", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "moduleResolution": "node" 9 | }, 10 | "include": [ 11 | "src/**/*", 12 | "tests/**/*" 13 | ], 14 | "exclude": [ 15 | "node_modules", 16 | "dist" 17 | ] 18 | } 19 | --------------------------------------------------------------------------------