| | |

Zustand 11 ⚛️ Transient Updates — Subscribing to state without re-renders

⏱️ Estimated read: 12 min | |

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

MethodPurpose
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

MiddlewarePurpose
subscribeWithSelectorSelector-based subscribe
equalityFn optionCompare the selected value

Subscription Arguments

ArgumentMeaning
stateThe new state
prevStateThe previous state
countThe new selected value
prevCountThe previous selected value

Cleanup

PatternCode
Setupconst unsub = store.subscribe(...)
Cleanupreturn unsub in useEffect
Imperative readstore.getState().value
Imperative writestore.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

PitfallWhy It HappensFix
Subscription fires too oftenPlain subscribe fires on every changeUse subscribeWithSelector
Memory leakNo cleanupReturn the unsubscribe from useEffect
Object selector fires on every changeDefault Object.is compares referencesUse shallow as the equalityFn
Component re-renders unnecessarilyUsed useStore instead of subscribeUse the subscription
getState returns stale dataRead outside the render without subscribingUse 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

ItemValue
Subscription methodstore.subscribe(listener)
Selector subscriptionstore.subscribe(selector, listener) (with middleware)
MiddlewaresubscribeWithSelector
Imperative readstore.getState()
Imperative writestore.setState(partial)
CleanupReturn the unsubscribe from useEffect
Equality functionObject.is (default), shallow (for objects)
Re-renderThe subscription does not cause one
Use caseCanvas, chart, game loop, imperative API

Key takeaways:

  • The subscribe method 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 a useEffect and return the unsubscribe function as the cleanup .
  • The subscribeWithSelector middleware scopes the subscription. The subscribe(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 getState and setState methods are the imperative API. The getState reads the current value without subscribing, and the setState writes 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 useEffect to prevent the listener from running after the component unmounts .
  • The equalityFn option is the comparison function. The default Object.is compares the references, and the shallow function compares the object properties. The option is the third argument to the subscribe call 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 subscribe call 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.


Was this helpful?


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!