zustand-performance-patterns

star 1

Apply performance-oriented patterns for Zustand stores including normalized state, granular selectors, decoupled pure-function actions, transient updates for high-frequency state, and imperative getState() escape hatches. Use this skill whenever a user works with Zustand and mentions performance, re-renders, normalized data, selector optimization, high-frequency state changes, or wants to structure a store that handles relational data, real-time UI, or complex event-driven logic.

Miskamyasa By Miskamyasa schedule Updated 6/2/2026

name: zustand-performance-patterns description: Apply performance-oriented patterns for Zustand stores including normalized state, granular selectors, decoupled pure-function actions, transient updates for high-frequency state, and imperative getState() escape hatches. Use this skill whenever a user works with Zustand and mentions performance, re-renders, normalized data, selector optimization, high-frequency state changes, or wants to structure a store that handles relational data, real-time UI, or complex event-driven logic.

Zustand Performance Patterns

1. Normalized state

Problem

Nested arrays force you to iterate, find, and clone deeply on every update. A change to one item re-creates the entire array, and every component selecting that array re-renders.

Solution

Model the store like a flat database. Entities live in Record<string, T> lookup maps. Ordering is a separate array of IDs.

interface Item {
  id: string;
  name: string;
  start: number;
  duration: number;
}

interface Track {
  id: string;
  itemIds: string[];
}

function initialState() {
  return {
    items: {} as Record<string, Item>,
    tracks: {} as Record<string, Track>,
    trackOrder: [] as string[],
  };
}

Why this matters

  • Updating one item means writing to a single key in the items map. No array cloning.
  • A selector like state.items[id] returns a stable reference when other items change, so only the affected component re-renders.
  • Ordering (trackOrder, itemIds) is a lightweight array of strings, cheap to diff.

Update example

updateItem: (id: string, patch: Partial<Item>) => {
  set((state) => {
    const existing = state.items[id];
    if (!existing) {
      return;
    }
    state.items[id] = { ...existing, ...patch };
  });
},

When using Immer middleware, mutate the draft directly with typed property writes:

updateItem: (id: string, patch: Partial<Item>) => {
  set((draft) => {
    const item = draft.items[id];
    if (!item) {
      return;
    }
    if (patch.name !== undefined) {
      item.name = patch.name;
    }
    if (patch.duration !== undefined) {
      item.duration = patch.duration;
    }
  });
},

Avoid Object.assign -- it bypasses TypeScript's type checking and silently accepts properties that don't belong on the target type.

Deriving nested views

When a component needs a "track with its items resolved", derive it in the selector or a memoized hook -- never store the nested form.

const trackItems = useStore(useShallow((state) => {
  const track = state.tracks[trackId];
  return track.itemIds.map((id) => state.items[id]);
}));

If this selector returns a new array reference every time, wrap it with a shallow equality check to avoid unnecessary re-renders.


2. Granular selectors

Rule

Never call useStore() without a selector. Every component subscribes to exactly the fields it renders.

// Bad -- re-renders on any state change
const state = useStore();

// Good -- re-renders only when fps changes
const fps = useStore((s) => s.fps);

// Good -- re-renders only when this specific track's items change
const itemIds = useStore(useShallow((s) => s.tracks[trackId].itemIds));

Why this matters

Zustand compares the selector's return value between state updates using Object.is by default. If the selector returns the same primitive or the same reference, the component skips re-rendering. Broad selectors defeat this because they return a new object on every change.

Primitive values: one selector per value

When selecting primitive fields, use a separate useStore call for each. Grouping primitives into an object reduces the maintainability of the selector.

// Bad -- reduces maintainability
const { name, duration } = useStore(
  useShallow((s) => ({
    name: s.items[id].name,
    duration: s.items[id].duration,
  })),
);

// Good -- each primitive has its own selector, no need for shallow equality and better maintainability
const name = useStore((s) => s.items[id].name);
const duration = useStore((s) => s.items[id].duration);

Composite values: shallow equality

Reserve useShallow for selectors that must return an object or array -- cases where the value is inherently composite and cannot be split into independent primitives.

import { useShallow } from "zustand/react/shallow";

// Array of IDs -- genuinely composite, cannot be split
const itemIds = useStore(
  useShallow((s) => s.tracks[trackId].itemIds),
);

Calling actions

Actions are not reactive data -- subscribing to them via a selector is unnecessary. Call them directly through getState():

function handleClick() {
  useStore.getState().addItem("new-item");
}

This keeps the component subscribed only to the state it renders, and avoids pulling action references into the React render cycle.


3. Decoupled functional actions

Problem

Embedding complex business logic directly inside set() callbacks makes it untestable and hard to read. The store file grows into a monolith.

Solution

Write business logic as pure functions that accept state (or a relevant slice) and return new state. The store action becomes a thin wrapper.

// logic/splitItem.ts -- pure, testable, no Zustand dependency
function splitItemLogic(
  items: Record<string, Item>,
  id: string,
  frame: number,
): Record<string, Item> {
  const item = items[id];
  if (!item || frame <= item.start || frame >= item.start + item.duration) {
    return items;
  }
  const leftDuration = frame - item.start;
  const rightId = generateId();
  return {
    ...items,
    [id]: { ...item, duration: leftDuration },
    [rightId]: {
      ...item,
      id: rightId,
      start: frame,
      duration: item.duration - leftDuration,
    },
  };
}
// store action -- thin wrapper
splitItem: (id: string, frame: number) => {
  set((state) => {
    state.items = splitItemLogic(state.items, id, frame);
  });
},

Why this matters

  • The logic function is testable with plain objects -- no store setup, no React, no mocking.
  • Multiple actions can compose the same logic functions.
  • The store definition stays short and scannable.

Guidelines

  • Pure functions take the minimal slice they need, not the entire state.
  • They return a new value (or mutate a draft if called inside Immer's set).
  • Side effects (API calls, logging) stay in the store action, not in the logic function.

4. Transient updates for high-frequency state

Problem

State that changes at 60fps (playhead position, cursor coordinates, active snap points) causes React to re-render on every frame if consumed through useStore selectors. This kills performance.

Solution

Use Zustand's subscribe API to read high-frequency values and update the DOM or canvas imperatively, bypassing React's render cycle entirely.

useEffect(() => {
  const unsubscribe = useStore.subscribe(
    (state) => state.playheadFrame,
    (frame) => {
      // Directly update DOM -- no React re-render
      playheadRef.current.style.transform = `translateX(${frame * pxPerFrame}px)`;
    },
  );
  return unsubscribe;
}, []);

When to use transient updates

  • Playhead or scrubber position during playback.
  • Cursor/pointer coordinates during drag operations.
  • Scroll position synced across panels.
  • Any value that updates faster than React can usefully re-render.

When not to use them

  • State that drives conditional rendering (show/hide, list membership).
  • State that other components need to react to through normal React flow.
  • Anything where skipping a render would leave the UI inconsistent.

Combining with refs

For canvas or WebGL renderers, store the subscription value in a ref and read it in the render loop:

const frameRef = useRef(0);

useEffect(() => {
  return useStore.subscribe(
    (state) => state.playheadFrame,
    (frame) => { frameRef.current = frame; },
  );
}, []);

// In animation loop
function tick() {
  drawPlayhead(frameRef.current);
  requestAnimationFrame(tick);
}

5. Imperative getState() escape hatch

Problem

Inside complex event handlers (onMouseMove, onPointerDown, onKeyDown), React state from the last render is stale. Adding state to dependency arrays causes handler re-attachment and breaks gesture tracking.

Solution

Call useStore.getState() inside the callback to read the latest state at invocation time.

const handlePointerMove = useCallback((e: PointerEvent) => {
  const { items, snapPoints, zoom } = useStore.getState();
  const frame = screenToFrame(e.clientX, zoom);
  const nearest = findNearestSnap(frame, snapPoints);
  // ... use fresh values
}, []); // empty deps -- handler identity is stable

Why this matters

  • The handler reference never changes, so it can be attached once and left alone.
  • Every invocation reads the current truth, not a stale snapshot from render time.
  • No risk of the "stale closure" bug where a drag operation uses outdated positions.

When to use getState()

  • Inside addEventListener callbacks.
  • Inside requestAnimationFrame or setInterval loops.
  • Inside pointer/gesture handlers that must remain referentially stable.
  • Anywhere outside the React render cycle where you need current state.

When not to use it

  • Inside JSX or render logic -- use selectors instead so the component re-renders when state changes.
  • As a replacement for selectors in components that need to reflect state visually.

Decision guide

Situation Pattern
Storing entities with IDs Normalized state (Record + ID arrays)
Component reads one field Granular selector
Component reads multiple primitives Separate selector per value
Component reads composite value (object/array) Shallow-equality selector
Complex state transformation Decoupled pure function + thin action
60fps positional updates Transient subscribe + DOM/ref
Event handler needs fresh state getState() escape hatch

Anti-patterns

  • Nested arrays of entities: Forces full-array cloning and broad re-renders. Normalize instead.
  • Bare useStore() with no selector: Subscribes to everything. Always pass a selector function.
  • Business logic inside set(): Untestable and bloats the store. Extract to pure functions.
  • Selector-driven 60fps updates: React cannot keep up. Use subscribe for high-frequency values.
  • Closure-captured state in long-lived handlers: Goes stale. Use getState() at call time.
  • Storing derived data: Compute it in selectors or memoized hooks. Storing it duplicates truth and creates sync bugs.
  • Grouping primitives in one selector: Wrapping multiple primitive fields into an object selector forces re-renders. Use one selector per primitive.
  • Selecting actions via useStore((s) => s.action): Actions are not reactive data. Call them via useStore.getState().action() instead.
  • Object.assign in Immer drafts: Bypasses TypeScript type checking. Use explicit property assignment on the draft.
Install via CLI
npx skills add https://github.com/Miskamyasa/dotfiles --skill zustand-performance-patterns
Repository Details
star Stars 1
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator