zustand-computed is a lightweight, TypeScript-friendly middleware for the state management system Zustand. It's a simple layer which adds a transformation function after any state change in your store.
# one of the following
npm i zustand-computed
pnpm i zustand-computed
bun add zustand-computed
yarn add zustand-computed
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.
import { createComputed } from "zustand-computed"
const computed = createComputed((state) => ({
countSq: state.count ** 2,
}))
const useStore = create(
computed(
(set, get) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
// get() function has access to ComputedStore
square: () => set(() => ({ count: get().countSq })),
root: () => set((state) => ({ count: Math.floor(Math.sqrt(state.count)) })),
})
)
)
With types, the previous example would look like this:
import { createComputed } from "zustand-computed"
type Store = {
count: number
inc: () => void
dec: () => void
}
type ComputedStore = {
countSq: number
}
const computed = createComputed((state: Store): ComputedStore => ({
countSq: state.count ** 2,
}))
const useStore = create<Store>()(
computed(
(set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
// get() function has access to ComputedStore
square: () => set(() => ({ count: get().countSq })),
root: () => set((state) => ({ count: Math.floor(Math.sqrt(state.count)) })),
})
)
)
The store can then be used as normal in a React component or via the Zustand API.
function Counter() {
const { count, countSq, inc, dec } = useStore()
return (
<div>
<span>{count}</span>
<br />
<span>{countSq}</span>
<br />
<button onClick={inc}>+1</button>
<button onClick={dec}>-1</button>
</div>
)
}
Here's an example with the Immer middleware.
const computed = createComputed((state: Store) => { /* ... */ })
const useStore = create<Store>()(
devtools(
immer(
computed(
(set) => ({
count: 1,
inc: () =>
set((state) => {
// example with Immer middleware
state.count += 1
}),
dec: () => set((state) => ({ count: state.count - 1 })),
})
)
)
)
)
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:
// only recomputes when "count" changes
const computed = createComputed((state: Store) => { /* ... */ }, { keys: ["count"] })
// only recomputes when the current state's count does not equal the next state's count (same as above, but more explicit)
const computedWithShouldRecomputeFn = createComputed((state: Store) => { /* ... */ }, { shouldRecompute: (state, nextState) => {
return state.count !== nextState.count
} })
const useStore = create<Store, [["chrisvander/zustand-computed", ComputedStore]]>(
computed(
(set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
})
)
)
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. 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.