Zustand 11 ⚛️ Transient Updates — Subscribing to state without re-renders
A transient update is a subscription to a Zustand store that reads the state without causing the component to re-render. The useStore hook re-renders the component when the selected value changes, and that is the behavior that most components need. But some components need to react to a change without rendering—a canvas that redraws itself, a chart library that updates imperatively, a game loop that reads the player’s position on every frame. For those, the re-render is wasted work, and the subscribe method is the mechanism that avoids it .
The subscribe method is on the store object itself, not the hook. It accepts a listener that receives the new state and the previous state, and it returns an unsubscribe function. The listener runs whenever the state changes, regardless of which value changed. The subscribeWithSelector middleware is the enhancement that lets the listener fire only when a selected slice changes, which is the mechanism that makes a transient subscription precise . The getState and setState methods are the other half of the pattern: getState reads the current value without subscribing, and setState writes without a component.
Key point: store.subscribe(listener) fires on every state change, with the new state and the previous state as the arguments. The subscribeWithSelector middleware changes the signature to store.subscribe(selector, listener), so the listener fires only when the selector’s result changes. The subscription does not cause a re-render, because it is not a React subscription; it is a plain function call. The component must call useEffect to set up the subscription and return the unsubscribe function as the cleanup.
Why transient updates require the subscribe method
The re-render problem.
The useStore((state) => state.count) hook subscribes the component to count, and the component re-renders when count changes. The re-render is necessary when the value is displayed in the JSX, but it is unnecessary when the value is passed to an imperative API. A canvas component that draws a shape based on the mouse position does not need to re-render when the position changes; it needs to redraw the canvas. The subscribe method gives the component the value without the re-render .
The subscription-scope problem.
The plain subscribe fires on every change, even when the value the component cares about has not changed. A component that subscribes to the store to read count receives a notification when name changes, and it must check whether count changed before doing the work. The subscribeWithSelector middleware moves the comparison into the subscription, so the listener fires only when the selected slice changes . The middleware is opt-in because it changes the store’s type and the subscribe signature.
The cleanup problem.
The subscription is not tied to the React lifecycle. The component must set it up in a useEffect and return the unsubscribe function as the cleanup, or the subscription remains active after the component unmounts and the listener continues to run. The cleanup function is the return value of the subscribe call, and the useEffect returns it directly . Without the cleanup, the listener is a memory leak.
a. The subscribe method without the middleware
The subscribe method on the store accepts a listener that receives the new state and the previous state. It returns the unsubscribe function.
import { useEffect } from 'react'
import { useCounterStore } from './stores/counter'
function CounterLogger() {
useEffect(() => {
// Subscribe to every state change
const unsubscribe = useCounterStore.subscribe((state, prevState) => {
// This runs on every change, regardless of which value changed
if (state.count !== prevState.count) {
console.log('Count changed:', prevState.count, '→', state.count)
}
})
// Cleanup on unmount
return unsubscribe
}, [])
return null
}
The subscribe call returns the unsubscribe function, and the useEffect returns it as the cleanup . The listener receives the new state and the previous state, and it compares the count values before doing the work. The comparison is necessary because the plain subscribe fires on every change, including the changes to the values the component does not care about.
The getState and setState methods are the other half of the pattern.
// Read the current value without subscribing
const current = useCounterStore.getState().count
// Write without a component
useCounterStore.setState({ count: 10 })
The getState reads the value at the moment of the call, and it does not subscribe. The setState writes the value and notifies the subscribers, including the components that use the hook and the listeners that use the subscribe method . The two methods are the store’s imperative API, and they are used in the event handlers, the timers, and the animation loops.
b. The subscribeWithSelector middleware
The subscribeWithSelector middleware changes the subscribe signature to accept a selector and a listener. The listener fires only when the selector’s result changes.
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
interface CounterState {
count: number
name: string
increment: () => void
}
export const useCounterStore = create<CounterState>()(
subscribeWithSelector((set) => ({
count: 0,
name: 'counter',
increment: () => set((state) => ({ count: state.count + 1 })),
}))
)
The middleware is applied to the store creator, and it adds the selector-based subscribe signature. The store’s type is extended, and the subscribe call now accepts the selector as the first argument.
useEffect(() => {
// Subscribe only to the count changes
const unsubscribe = useCounterStore.subscribe(
(state) => state.count,
(count, prevCount) => {
console.log('Count changed:', prevCount, '→', count)
}
)
return unsubscribe
}, [])
The subscribe(selector, listener) call fires the listener only when the selector’s result changes . The count and prevCount are the selected values, not the full state, so the listener does not need to compare them. The selector is the mechanism that scopes the subscription to the specific slice.
The subscribe with the selector also accepts an equalityFn as the third argument, which is the comparison function for the selected value.
useCounterStore.subscribe(
(state) => state.user,
(user) => console.log('User changed:', user),
{ equalityFn: shallow }
)
The equalityFn option is the comparison function that the middleware uses to decide whether the selected value changed . The default is Object.is, and the shallow function from zustand/shallow is the alternative for the object selectors. The option is the third argument, and it is only available when the middleware is applied.
c. The transient subscription in an animation loop
The animation loop is the canonical use case. The component reads the player’s position on every frame, and it draws the new frame. The position changes on every frame, and the component does not need to re-render because the canvas is the output.
import { useEffect, useRef } from 'react'
import { useGameStore } from './stores/game'
function GameCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// The subscription reads the state without re-rendering
const unsubscribe = useGameStore.subscribe(
(state) => state.player,
(player) => {
// Redraw the canvas with the new position
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillRect(player.x, player.y, 20, 20)
}
)
return unsubscribe
}, [])
return <canvas ref={canvasRef} width={800} height={600} />
}
The subscribe call fires on every change to the player object, and the listener redraws the canvas . The component itself does not re-render, because it does not use the useStore hook. The canvas is the only element that changes, and it changes imperatively through the ctx API.
The same pattern applies to the chart libraries, the map libraries, and the third-party widgets that take the data as a prop and update themselves when the data changes.
Complete Example Session
// ============================================
// PART 1: THE STORE WITH THE MIDDLEWARE
// ============================================
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
interface CounterState {
count: number
name: string
increment: () => void
}
export const useCounterStore = create<CounterState>()(
subscribeWithSelector((set) => ({
count: 0,
name: 'counter',
increment: () => set((state) => ({ count: state.count + 1 })),
}))
)
// ============================================
// PART 2: THE PLAIN SUBSCRIPTION
// ============================================
import { useEffect } from 'react'
function CounterLogger() {
useEffect(() => {
const unsubscribe = useCounterStore.subscribe((state, prev) => {
if (state.count !== prev.count) {
console.log('Count:', prev.count, '→', state.count)
}
})
return unsubscribe
}, [])
return null
}
// ============================================
// PART 3: THE SELECTOR SUBSCRIPTION
// ============================================
function CountWatcher() {
useEffect(() => {
const unsubscribe = useCounterStore.subscribe(
(state) => state.count,
(count, prevCount) => {
console.log('Count changed:', prevCount, '→', count)
}
)
return unsubscribe
}, [])
return null
}
// ============================================
// PART 4: THE IMPERATIVE READ
// ============================================
function incrementByTen() {
const current = useCounterStore.getState().count
useCounterStore.setState({ count: current + 10 })
}
// ============================================
// PART 5: THE ANIMATION LOOP
// ============================================
function GameCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const unsubscribe = useGameStore.subscribe(
(state) => state.player,
(player) => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillRect(player.x, player.y, 20, 20)
}
)
return unsubscribe
}, [])
return <canvas ref={canvasRef} width={800} height={600} />
}
The five parts demonstrate the transient update surface: the store with the middleware, the plain subscription, the selector subscription, the imperative read and write, and the animation loop. The critical distinction is between Parts 2 and 3: the plain subscription fires on every change, and the selector subscription fires only when the selected value changes.
Quick Reference
Store Methods
| Method | Purpose |
|---|---|
useStore.subscribe(listener) | Subscribe to all changes |
useStore.subscribe(selector, listener) | Subscribe to a slice (with middleware) |
useStore.getState() | Read the current state |
useStore.setState(partial) | Write the state |
Middleware
| Middleware | Purpose |
|---|---|
subscribeWithSelector | Selector-based subscribe |
equalityFn option | Compare the selected value |
Subscription Arguments
| Argument | Meaning |
|---|---|
state | The new state |
prevState | The previous state |
count | The new selected value |
prevCount | The previous selected value |
Cleanup
| Pattern | Code |
|---|---|
| Setup | const unsub = store.subscribe(...) |
| Cleanup | return unsub in useEffect |
| Imperative read | store.getState().value |
| Imperative write | store.setState({ value }) |
Best Practices
✅ Do This:
// Use the selector subscription for precision
useStore.subscribe((s) => s.count, (count) => { ... }) # ✅
// Return the unsubscribe function from useEffect
useEffect(() => { const unsub = store.subscribe(...); return unsub }, []) # ✅
// Use getState for the imperative read
const value = store.getState().count # ✅
// Apply the middleware when the selector subscription is needed
create<State>()(subscribeWithSelector((set) => ({ ... }))) # ✅
// Use the animation loop for the canvas
store.subscribe((s) => s.player, (p) => redraw(p)) # ✅
❌ Don’t Do This:
// Subscribe without the cleanup
useEffect(() => { store.subscribe(...) }, []) // Leak # ❌
// Use the plain subscribe for a specific value
store.subscribe((state, prev) => { if (state.count !== prev.count) ... }) # ❌
// Read the state inside the render
const value = store.getState().count // Does not subscribe # ❌
// Use the hook when the re-render is not needed
const player = useStore((s) => s.player) // Re-renders per frame # ❌
// Forget the equalityFn for the object selectors
store.subscribe((s) => s.user, listener) // Fires on every change # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Subscription fires too often | Plain subscribe fires on every change | Use subscribeWithSelector |
| Memory leak | No cleanup | Return the unsubscribe from useEffect |
| Object selector fires on every change | Default Object.is compares references | Use shallow as the equalityFn |
| Component re-renders unnecessarily | Used useStore instead of subscribe | Use the subscription |
getState returns stale data | Read outside the render without subscribing | Use subscribe for reactive reads |
Real-World Examples
1. Plain subscription
useStore.subscribe((state, prev) => { if (state.count !== prev.count) ... })
2. Selector subscription
useStore.subscribe((s) => s.count, (count, prev) => ...)
3. Imperative read
const value = useStore.getState().count
4. Imperative write
useStore.setState({ count: 10 })
5. Cleanup
useEffect(() => { const unsub = store.subscribe(...); return unsub }, [])
6. Animation loop
store.subscribe((s) => s.player, (p) => redraw(p))
7. Shallow comparison
useStore.subscribe((s) => s.user, listener, { equalityFn: shallow })
8. Middleware
create<State>()(subscribeWithSelector((set) => ({ ... })))
9. Canvas redraw
store.subscribe((s) => s.data, (data) => chart.update(data))
10. Game loop
requestAnimationFrame(function loop() {
const { player } = useGameStore.getState()
render(player)
requestAnimationFrame(loop)
})
Visual
The Subscription vs the Hook
┌──────────────────────────────────────────────────────────────┐
│ THE HOOK RE-RENDERS; THE SUBSCRIBE DOES NOT │
│ │
│ useStore((s) => s.count): │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ count changes → component re-renders │ │
│ │ → new JSX is produced │ │
│ │ → the DOM is updated │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ useStore.subscribe((s) => s.count, listener): │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ count changes → listener runs │ │
│ │ → no re-render │ │
│ │ → the listener does the work directly │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The Selector Subscription
┌──────────────────────────────────────────────────────────────┐
│ subscribeWithSelector SCOPES THE SUBSCRIPTION │
│ │
│ store.subscribe((s) => s.count, listener) │
│ │ │
│ ▼ │
│ count changes → listener runs │
│ name changes → listener does NOT run │
│ │
│ The selector is compared with the previous result. │
│ The listener fires only when the result changes. │
└──────────────────────────────────────────────────────────────┘
The Cleanup
┌──────────────────────────────────────────────────────────────┐
│ THE SUBSCRIPTION MUST BE CLEANED UP │
│ │
│ useEffect(() => { │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ const unsub = store.subscribe(...) │ │
│ │ return unsub ← the cleanup │ │
│ └────────────────────────────────────────────────────────┘ │
│ }, []) │
│ │
│ Without the cleanup, the listener remains active after │
│ the component unmounts. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Subscription method | store.subscribe(listener) |
| Selector subscription | store.subscribe(selector, listener) (with middleware) |
| Middleware | subscribeWithSelector |
| Imperative read | store.getState() |
| Imperative write | store.setState(partial) |
| Cleanup | Return the unsubscribe from useEffect |
| Equality function | Object.is (default), shallow (for objects) |
| Re-render | The subscription does not cause one |
| Use case | Canvas, chart, game loop, imperative API |
Key takeaways:
- The
subscribemethod is the transient subscription. It fires on every state change, with the new and previous state, and it does not cause a re-render. The component must set it up in auseEffectand return the unsubscribe function as the cleanup . - The
subscribeWithSelectormiddleware scopes the subscription. Thesubscribe(selector, listener)form fires the listener only when the selected value changes, which is the mechanism that avoids the unnecessary work. The middleware is applied to the store creator . - The
getStateandsetStatemethods are the imperative API. ThegetStatereads the current value without subscribing, and thesetStatewrites the value and notifies the subscribers. The two methods are used in the event handlers, the timers, and the animation loops . - The cleanup is mandatory. The subscription is not tied to the React lifecycle, and the component must return the unsubscribe function from the
useEffectto prevent the listener from running after the component unmounts . - The
equalityFnoption is the comparison function. The defaultObject.iscompares the references, and theshallowfunction compares the object properties. The option is the third argument to thesubscribecall with the selector . - The animation loop is the canonical use case. The component reads the player’s position on every frame and redraws the canvas, without a re-render. The
subscribecall is the mechanism that keeps the canvas in sync with the store.
Remember: A transient update is a subscription that reads the state without re-rendering. The store.subscribe method is the mechanism, and the subscribeWithSelector middleware is the precision tool. The getState and setState are the imperative API, and the cleanup is mandatory. The animation loop is the canonical use case, and the canvas is the output. The result is a component that reacts to the state changes without the re-render overhead, which is the difference between a smooth animation and a stuttering one.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!