typescript.
typescript42 min read

Zustand Selector Re-Renders and useShallow

Zustand Selector Re-Renders and useShallow

The bug that finally made me read the Zustand docs front to back was a profile dropdown that re-rendered every time a websocket pushed a presence update for someone else in the workspace. Forty re-renders per second on a component that displayed my own avatar. I had wired the dropdown to the store with a plain useStore((s) => s) because that is what the example in our internal wiki showed, and for months nobody noticed — until we added presence and the React DevTools profiler turned into a Christmas tree. The fix turned out to be three characters and a hook I had been skeptical of, and the embarrassing part is that the docs had been telling me about it the whole time. I just had not internalised why subscribing to the whole store was the worst thing I could do.

This article is the walkthrough I wish someone had handed me that afternoon. You will scaffold a TypeScript + React project with Zustand, deliberately build the broken whole-store subscription, instrument it with a render counter, and then refactor through single-field selectors before reaching for useShallow when you genuinely need multiple fields at once. By the end you will have a small repo where each step is a commit, the render counts drop from the dozens to the single digits, and the TypeScript signatures stay honest about what each selector returns. The rule worth screenshotting: a selector that returns the whole store is not a selector, it is a subscription to every keystroke in your application.

This is written for React developers who already use Zustand in production and have started to suspect their components re-render more than they should. By the last section you will be able to read a selector, predict its render behaviour, and know exactly when useShallow is the right tool and when it is a footgun dressed up as an optimisation.

Step 1: Wiring a Strict-Typed Zustand Profile Store

Before we can talk about selector re-renders or useShallow, we need an honest baseline: a tiny project that holds real state, with a real type system, and tests that pin down the contract. This step builds exactly that — a Bun + TypeScript + React + Zustand workspace whose only job is to expose a ProfileState slice with shallow setters, a nested preferences object, and a reset action. Every later step in this series will modify how components subscribe to this store, so the store itself has to be boring and trustworthy.

The angle we are setting up is deliberate. The store holds a flat top level (name, age, email) plus one nested object (preferences) so that we can later contrast field selectors, object selectors, and useShallow against the same surface. If the baseline did not have a nested slice, the eventual re-render demonstration would not bite — there would be no reason for a referential-equality footgun to show up.

Setup

The codebase lives in a fresh codebase/ directory with no framework generator and no bundler. We pick Bun as the runner because it ships a TypeScript-aware test command out of the box and avoids a separate Jest configuration. React and Zustand come in as runtime dependencies, and the type packages plus the strict TypeScript compiler are dev dependencies.

Three files describe the project skeleton. package.json declares the dependency set and two scripts (test and typecheck). tsconfig.json enables every safety flag we care about (strict, noUncheckedIndexedAccess, noImplicitOverride, exactOptionalPropertyTypes). The actual source files live under src/: store.ts for the typed store and store.test.ts for the contract tests.

{
  "name": "zustand-selector-re-renders-useshallow",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "bun test",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "zustand": "^4.5.5"
  },
  "devDependencies": {
    "@types/bun": "^1.1.10",
    "@types/react": "^18.3.11",
    "@types/react-dom": "^18.3.0",
    "typescript": "^5.6.2"
  }
}
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "types": ["bun", "react"],
    "noEmit": true
  },
  "include": ["src/**/*"]
}

Run bun install once after writing those two files; it produces bun.lock and a node_modules/ tree. Nothing else is needed before we author the store.

Implementation

The store has three logical pieces: the type definitions, the immutable initial values, and the create<ProfileState> call that ties them together. We deliberately keep the slice tiny — five fields plus six actions — because a small surface makes the later selector experiments easy to read at a glance.

import { create } from "zustand";

export type Theme = "light" | "dark";

export interface ProfilePreferences {
  theme: Theme;
  notifications: boolean;
}

export interface ProfileState {
  name: string;
  age: number;
  email: string;
  preferences: ProfilePreferences;
  setName: (name: string) => void;
  setAge: (age: number) => void;
  setEmail: (email: string) => void;
  setTheme: (theme: Theme) => void;
  setNotifications: (notifications: boolean) => void;
  reset: () => void;
}

The ProfileState interface mixes data fields and action signatures in the same shape — that is the canonical Zustand pattern. Splitting actions into a separate slice is a refactor we can do later, but for the baseline we want the type to read like a contract a component can ask "do you have this method?" against. Declaring Theme as a union of two string literals turns setTheme("blue") into a compile-time error, which is the strict-mode payoff we paid for in tsconfig.json.

const initialState = {
  name: "Ada",
  age: 36,
  email: "ada@example.com",
  preferences: {
    theme: "light" as Theme,
    notifications: true,
  },
};

export const useProfileStore = create<ProfileState>((set) => ({
  ...initialState,
  setName: (name) => set({ name }),
  setAge: (age) => set({ age }),
  setEmail: (email) => set({ email }),
  setTheme: (theme) =>
    set((state) => ({
      preferences: { ...state.preferences, theme },
    })),
  setNotifications: (notifications) =>
    set((state) => ({
      preferences: { ...state.preferences, notifications },
    })),
  reset: () => set({ ...initialState }),
}));

Hoisting initialState to a module-level constant gives us two things at once: a single source of truth for default values and a clean argument for reset. The top-level setters use the shorthand set({ field }) form, which lets Zustand merge the patch with the rest of the state automatically. The nested setters take the functional form set((state) => ...) and rebuild preferences with the spread operator, so the nested object identity changes on every mutation — that referential change is what will eventually make naive selectors over-render in later steps.

The reset action spreads initialState so the store snaps back to fresh defaults. Because the actions are part of the state shape, the spread does not overwrite them; Zustand's shallow merge keeps the existing function references intact. That detail is easy to miss, and it is exactly the kind of invariant we want the test suite to pin down before it can silently break.

import { describe, expect, test, beforeEach } from "bun:test";
import { useProfileStore } from "./store";

describe("useProfileStore", () => {
  beforeEach(() => {
    useProfileStore.getState().reset();
  });

  test("exposes typed initial state", () => {
    const state = useProfileStore.getState();
    expect(state.name).toBe("Ada");
    expect(state.age).toBe(36);
    expect(state.email).toBe("ada@example.com");
    expect(state.preferences.theme).toBe("light");
    expect(state.preferences.notifications).toBe(true);
  });

  test("setTheme updates nested preferences immutably", () => {
    const before = useProfileStore.getState().preferences;
    useProfileStore.getState().setTheme("dark");
    const after = useProfileStore.getState().preferences;
    expect(after.theme).toBe("dark");
    expect(after.notifications).toBe(true);
    expect(after).not.toBe(before);
  });

  test("subscribe fires when relevant slice changes", () => {
    let calls = 0;
    const unsubscribe = useProfileStore.subscribe(() => {
      calls += 1;
    });
    useProfileStore.getState().setName("Linus");
    useProfileStore.getState().setAge(54);
    unsubscribe();
    useProfileStore.getState().setEmail("linus@example.com");
    expect(calls).toBe(2);
  });
});

Three of the eight tests are shown above; the rest follow the same pattern for setName, setAge, setEmail, setNotifications, and reset. The interesting assertion is expect(after).not.toBe(before) — it verifies that the nested preferences object is a new reference after a setter call. That is the exact behavior that will later trip up any component that subscribes with s => ({ theme: s.preferences.theme }) without a shallow comparator.

The subscribe test pins down the second half of the contract: store subscribers fire once per set call, regardless of whether the new value differs from the old. The unsubscribe path proves that the cleanup function actually detaches the listener — the third mutation does not increment the counter.

Verification

bun test
bun test v1.2.19 (aad3abea)

 8 pass
 0 fail
 19 expect() calls
Ran 8 tests across 1 file. [34.00ms]
bun run typecheck
$ tsc --noEmit

tsc --noEmit exits cleanly with no output, which is the strict-mode "everything is fine" signal — any string-literal violation or missing field on ProfileState would surface here as a diagnostic.

What we built

We now have a self-contained TypeScript + React + Zustand workspace whose store is fully typed and whose contract is pinned down by an eight-test Bun suite. The store exposes flat fields, one nested object, and a reset action — the minimum surface needed to reason about selector behavior in later steps.

Two invariants are locked in. Every nested mutation produces a new preferences object identity, and every top-level set notifies subscribers exactly once. Those two facts are what make the upcoming selector demonstrations meaningful instead of accidental.

Nothing renders yet, and that is on purpose. We will not introduce React components until the store contract is boring enough to take for granted. Step 2 will mount the store inside a real component tree and measure how a naive selector behaves when neighboring fields change.

The strict tsconfig.json also gives us a safety net for everything that follows. Selector functions in later steps will be typed against ProfileState, so an out-of-date selector signature will fail bun run typecheck before it ever has a chance to fail at runtime.

Repository

The state of the code after this step: 384f0ed

Step 2: Measuring Re-Render Bleed with a Whole-Store ProfileCard

Step 1 left us with a boring, fully-typed Zustand store and an eight-test contract that pins down its mutation behavior. Nothing renders yet, so the cost of a sloppy subscription is still invisible. This step introduces a ProfileCard component that calls useProfileStore() with no selector at all, instruments it with a render counter, and then drives a Bun test suite through react-test-renderer to make the unwanted re-renders observable in CI.

The reason we start with the worst-case subscription is purely pedagogical. Without a measurable baseline, "this pattern is slow" is just folklore. Once a numeric render count exists, every later refactor — single-field selector, object selector, useShallow — has a quantitative bar to clear. The whole-store subscription gives us that bar.

Setup

Two new files appear under codebase/src/: ProfileCard.tsx for the component plus its render-counter helpers, and ProfileCard.test.tsx for the verification suite. The store from step 1 stays untouched — we only consume it, never amend it.

The component renders inside a headless test environment, so we add two dev dependencies: react-test-renderer@18.3.1 (the host-agnostic React renderer) and its companion type package @types/react-test-renderer@18.3.0. These keep the project free of jsdom and a browser-style DOM, which would be overkill for measuring render counts. The diff in package.json is two added lines under devDependencies; running bun install refreshes bun.lock.

"devDependencies": {
  "@types/bun": "^1.1.10",
  "@types/react": "^18.3.11",
  "@types/react-dom": "^18.3.0",
  "@types/react-test-renderer": "18.3.0",
  "react-test-renderer": "18.3.1",
  "typescript": "^5.6.2"
}

No other configuration changes are needed. The strict tsconfig.json from step 1 already covers JSX (jsx: "react-jsx") and the React typings, so the new .tsx files compile cleanly without a fresh switch.

Implementation

The component itself is intentionally minimal. It calls useProfileStore() with no arguments, which means Zustand hands it the entire state object on every store update and treats the whole snapshot as the equality target. Every mutation in the store — even ones to fields the component never reads — produces a new top-level reference and triggers a re-render.

import { useProfileStore } from "./store";

let renderCount = 0;

export const getRenderCount = (): number => renderCount;

export const resetRenderCount = (): void => {
  renderCount = 0;
};

export const ProfileCard = (): JSX.Element => {
  const state = useProfileStore();
  renderCount += 1;
  return (
    <section aria-label="profile-card">
      <p data-testid="name">{state.name}</p>
      <p data-testid="age">{state.age}</p>
      <p data-testid="email">{state.email}</p>
    </section>
  );
};

renderCount is a module-level counter so the assertions in the test file can read it without prop-drilling or context. getRenderCount and resetRenderCount are exported as plain functions instead of an object so each test can call resetRenderCount() in beforeEach without worrying about identity. Incrementing inside the function body — rather than inside useEffect — is deliberate: it counts commits during the render phase exactly as React would invoke them, which is what we want to measure.

The body renders name, age, and email and deliberately ignores preferences.theme and preferences.notifications. That asymmetry is the whole point of the experiment. The test cases will mutate the unread fields and prove that the component still re-renders.

import { describe, expect, test, beforeEach } from "bun:test";
import TestRenderer from "react-test-renderer";
import { useProfileStore } from "./store";
import {
  ProfileCard,
  getRenderCount,
  resetRenderCount,
} from "./ProfileCard";

const { act, create } = TestRenderer;

const mount = (): TestRenderer.ReactTestRenderer => {
  let root: TestRenderer.ReactTestRenderer | undefined;
  act(() => {
    root = create(<ProfileCard />);
  });
  if (!root) {
    throw new Error("renderer failed to mount");
  }
  return root;
};

The mount helper wraps create(<ProfileCard />) in act(...) so React flushes the initial render synchronously before the test inspects renderCount. Without act, the counter would still be 0 when the first assertion runs, and the suite would look broken in a way that misleads readers. The if (!root) guard is there to satisfy noUncheckedIndexedAccess in strict mode — TypeScript otherwise infers root as possibly undefined after the act callback.

describe("ProfileCard (whole-store subscription)", () => {
  beforeEach(() => {
    useProfileStore.getState().reset();
    resetRenderCount();
  });

  test("mounts with one render and shows initial state", () => {
    const root = mount();
    const tree = root.toJSON();
    expect(getRenderCount()).toBe(1);
    expect(JSON.stringify(tree)).toContain("Ada");
    expect(JSON.stringify(tree)).toContain("ada@example.com");
    root.unmount();
  });

  test("re-renders when notifications flips — the field is NEVER read", () => {
    const root = mount();
    expect(getRenderCount()).toBe(1);
    act(() => {
      useProfileStore.getState().setNotifications(false);
    });
    expect(getRenderCount()).toBe(2);
    act(() => {
      useProfileStore.getState().setNotifications(true);
    });
    expect(getRenderCount()).toBe(3);
    root.unmount();
  });
});

The first test pins down the baseline — a clean mount is exactly one render — and verifies that the rendered tree contains the seeded values. The second test is the indictment: flipping notifications twice, a field the component never reads, drives the render count from one to three. Each mutation costs an extra commit, and there is no selector layer to absorb the change. The full file adds three more cases (a displayed-field mutation as a sanity check, a theme toggle, and a five-mutation storm) but they all rest on the same mechanism.

Verification

bun test src/ProfileCard.test.tsx
bun test v1.2.19 (aad3abea)

 5 pass
 0 fail
 12 expect() calls
Ran 5 tests across 1 file. [186.00ms]

All five cases pass, which in this context is a paradoxical good-news/bad-news result. The good news is that our measurement instrument is reliable and reproducible. The bad news — and the motivation for steps 3 onward — is that the assertions getRenderCount() === 3 after two unrelated mutations are exactly the over-rendering we want to eliminate.

What we built

We added a ProfileCard component that demonstrates the cheapest possible Zustand subscription and the most expensive runtime behavior. Calling useProfileStore() with no selector hands the component the whole state on every update, and React responds by re-rendering on every commit.

Around the component we wrapped a render-counter API — getRenderCount, resetRenderCount — that lets a headless renderer measure commits without any DevTools integration. Because the counter is a module-level number, it survives across act boundaries and is trivially reset between tests.

The Bun + react-test-renderer suite then turns the demonstration into a regression contract. Two mutations to preferences.notifications produce three renders; a theme toggle produces an extra render; a five-mutation storm produces at least one extra render per mutation. Those numbers are now the bar that any future selector strategy has to beat.

Step 3 will replace the whole-store call with a field selector (s => s.name) and re-run the exact same suite. The expectation is that the unread-field tests will fail in the new direction — the render count should stop incrementing on notifications mutations — at which point we will update the assertions and document the win.

Repository

The state of the code after this step: 2cfd9d4

Step 3: Cutting Render Bleed with Single-Field Selectors

Step 2 left us with a ProfileCard that subscribed to the whole store and a five-test suite that pinned down the unwanted re-render behavior in CI. The numbers were the indictment: two mutations to a never-read field produced three renders, and a five-mutation storm produced one render per mutation. This step refactors the subscription pattern into one selector per field, mounts both components side by side, and turns the previous failure mode into a passing assertion that the new component renders zero times under the same five-mutation storm.

The point of comparing both components in the same suite is to make the win measurable rather than aesthetic. A single selector-based component on its own can only show a low number; running it alongside the old whole-store component shows that the low number is a delta, not a baseline that would have been low anyway. That comparison is what makes the next step — replacing two selectors with one useShallow call — easy to justify.

Setup

No new runtime dependencies are needed. The react, zustand, and react-test-renderer packages installed in steps 1 and 2 already cover everything the selector refactor requires, and the strict tsconfig.json from step 1 still types the new files without changes.

Two new source files appear under codebase/src/: ProfileCardSelectors.tsx for the refactored component plus its own render-counter API, and ProfileCardSelectors.test.tsx for an eight-case Bun suite. The previous ProfileCard.tsx and its test file stay in place — we deliberately keep both components in the tree so the comparison suite can mount them next to each other. The store.ts module is untouched.

Implementation

The new component looks almost identical to the old one at the JSX level. The only meaningful difference is the subscription shape: three useProfileStore calls, each with a single-field selector, replace the lone useProfileStore() call from step 2. Zustand will now compare each selector's return value against the previous value with Object.is, and it will only schedule a re-render when one of those primitive comparisons fails.

import { useProfileStore } from "./store";

let renderCount = 0;

export const getSelectorRenderCount = (): number => renderCount;

export const resetSelectorRenderCount = (): void => {
  renderCount = 0;
};

export const ProfileCardSelectors = (): JSX.Element => {
  const name = useProfileStore((s) => s.name);
  const age = useProfileStore((s) => s.age);
  const email = useProfileStore((s) => s.email);
  renderCount += 1;
  return (
    <section aria-label="profile-card-selectors">
      <p data-testid="name">{name}</p>
      <p data-testid="age">{age}</p>
      <p data-testid="email">{email}</p>
    </section>
  );
};

The render-counter API is duplicated rather than reused on purpose. The whole-store renderCount from ProfileCard.tsx and the selector renderCount here are two independent module-level numbers, so the comparison test can reset both before a run and read each one without interference. Calling the exported reset twice in beforeEach is the discipline that keeps the two counters honest across cases.

Picking three primitive selectors instead of one object selector like (s) => ({ name, age, email }) is the key design choice for this step. Each call returns a string or number, so Object.is short-circuits perfectly: setting name to the same "Ada" will not re-render, and a notifications flip will never even reach the equality check for name. An object selector would defeat both properties and re-trigger the same re-render bleed we are trying to delete — that is the trap step 4 will untangle with useShallow.

test("does NOT re-render when notifications flips — the field is never selected", () => {
  const root = mountSelectors();
  expect(getSelectorRenderCount()).toBe(1);

  act(() => {
    useProfileStore.getState().setNotifications(false);
  });
  expect(getSelectorRenderCount()).toBe(1);

  act(() => {
    useProfileStore.getState().setNotifications(true);
  });
  expect(getSelectorRenderCount()).toBe(1);
  root.unmount();
});

The flagship assertion is the one shown above: after two setNotifications calls, the counter is still 1. The same shape carries over to a theme toggle and to a setName("Ada") no-op, both of which now hold the counter steady. That trio is what the whole-store baseline could never achieve.

test("five unrelated mutations: whole-store re-renders, selectors stay flat", () => {
  const selectorRoot = mountSelectors();
  const wholeRoot = mountWhole();

  const baselineSelectors = getSelectorRenderCount();
  const baselineWhole = getRenderCount();
  expect(baselineSelectors).toBe(1);
  expect(baselineWhole).toBe(1);

  act(() => {
    useProfileStore.getState().setNotifications(false);
    useProfileStore.getState().setTheme("dark");
    useProfileStore.getState().setNotifications(true);
    useProfileStore.getState().setTheme("light");
    useProfileStore.getState().setNotifications(false);
  });

  const selectorRenders = getSelectorRenderCount() - baselineSelectors;
  const wholeRenders = getRenderCount() - baselineWhole;

  expect(selectorRenders).toBe(0);
  expect(wholeRenders).toBeGreaterThanOrEqual(1);
  expect(wholeRenders).toBeGreaterThan(selectorRenders);
  selectorRoot.unmount();
  wholeRoot.unmount();
});

The comparison test batches five mutations to fields that neither component reads, then computes a delta from the baseline render count for each component. The new component contributes a delta of zero; the old component contributes at least one. The exact whole-store delta varies because React may batch consecutive mutations inside the same act callback into a single commit, which is precisely why the assertion is toBeGreaterThanOrEqual(1) rather than toBe(5) — we are asserting the qualitative gap, not the batching policy.

Verification

bun test src/ProfileCardSelectors.test.tsx
bun test v1.2.19 (aad3abea)

 8 pass
 0 fail
 26 expect() calls
Ran 8 tests across 1 file. [398.00ms]

All eight cases pass, including the cross-component comparison. The two cases that mirror step 2's failure mode — a notifications flip and a theme toggle — now assert that the selector counter stays at 1, which is exactly the inversion we wanted.

What we built

We introduced a ProfileCardSelectors component that subscribes to the store one primitive field at a time. The render-counter API is now duplicated and isolated per component, so a single Bun suite can hold both components in memory and reason about their behavior independently.

The new suite encodes three classes of guarantee. Unread-field mutations produce zero extra renders, same-value sets short-circuit through Object.is, and selected-field mutations still bump the counter exactly once per change. Those three guarantees together rule out both over-rendering and under-rendering as silent regressions in any future refactor.

The cross-component comparison test is the headline result. Under the same five-mutation storm that drove the whole-store component to at least one extra render, the selector component records a delta of zero. The qualitative gap — wholeRenders > selectorRenders — is now a hard assertion rather than an anecdote, and it will fail loudly if a future change reintroduces a broader subscription anywhere in the chain.

What this step deliberately does not solve is the case where a component needs to read several fields as a single derived object. A naive (s) => ({ name, age }) selector would reintroduce the bleed we just eliminated, because Zustand's default Object.is check would treat every fresh object literal as unequal. Step 4 will pick up that exact trap and resolve it with useShallow.

Repository

The state of the code after this step: f4dee0d

Step 4: Collapsing Object-Selector Bleed with useShallow

Step 3 fixed the whole-store re-render storm by splitting one subscription into three single-field selectors, and the new test suite proved the delta with a side-by-side mount. That solution works perfectly when every consumed field is a primitive, but it does not survive contact with a component that needs to read several fields as one object. The moment a selector returns { name, age, email }, Zustand's default Object.is check sees a brand-new object literal on every store change and re-triggers the exact bleed we just deleted. This step picks up that trap and resolves it by wrapping the object selector in useShallow, which replaces the reference check with a key-by-key shallow comparison.

The shape of the proof mirrors step 3 on purpose. We mount a deliberately broken ProfileCardObjectNaive next to a corrected ProfileCardShallow in the same Bun suite, then drive both through the five-mutation storm from earlier steps. The naive component re-renders on every unrelated flip; the useShallow component records a delta of zero — and a focused mutation on a selected field still bumps both counters by exactly one, so we know the optimization did not accidentally swallow real updates.

Setup

The only new runtime dependency is the already-installed zustand package: useShallow is exported from its zustand/react/shallow sub-path, so no bun install step is required. The strict tsconfig.json from step 1 still types the new files without changes, and react-test-renderer continues to drive the assertions from a headless environment.

Two new source files appear under codebase/src/: ProfileCardShallow.tsx houses both the broken and corrected components plus a pair of render-counter APIs, and ProfileCardShallow.test.tsx carries an eight-case suite split across three describe blocks. The earlier components from steps 2 and 3 stay in the tree untouched — we want to keep all four subscription patterns coexisting so the article reads as a progression rather than a rewrite.

Implementation

The file co-locates the naive object selector and the useShallow fix because the only meaningful difference between them is a single wrapper call. Keeping them side by side in the same module makes the diff legible at a glance and lets the test suite import both components from one path.

import { useShallow } from "zustand/react/shallow";
import { useProfileStore } from "./store";

let shallowRenderCount = 0;
let naiveRenderCount = 0;

export const getShallowRenderCount = (): number => shallowRenderCount;
export const resetShallowRenderCount = (): void => {
  shallowRenderCount = 0;
};
export const getNaiveRenderCount = (): number => naiveRenderCount;
export const resetNaiveRenderCount = (): void => {
  naiveRenderCount = 0;
};

The two module-level counters are intentionally independent — each component bumps its own number, and each comes with a get/reset pair. This mirrors the pattern from step 3 and is the discipline that lets one suite hold both components in memory without their counters polluting each other.

export const ProfileCardObjectNaive = (): JSX.Element => {
  const { name, age, email } = useProfileStore((s) => ({
    name: s.name,
    age: s.age,
    email: s.email,
  }));
  naiveRenderCount += 1;
  return (
    <section aria-label="profile-card-object-naive">
      <p data-testid="name">{name}</p>
      <p data-testid="age">{age}</p>
      <p data-testid="email">{email}</p>
    </section>
  );
};

ProfileCardObjectNaive is the trap from step 3's closing paragraph turned into runnable code. The selector returns a fresh object literal on every invocation, and Zustand compares that literal against the previous one with Object.is. Two different object references are never identical, so the comparison always reports change and the component re-renders on every store mutation — including ones that touched fields the selector never reads.

export const ProfileCardShallow = (): JSX.Element => {
  const { name, age, email } = useProfileStore(
    useShallow((s) => ({
      name: s.name,
      age: s.age,
      email: s.email,
    })),
  );
  shallowRenderCount += 1;
  return (
    <section aria-label="profile-card-shallow">
      <p data-testid="name">{name}</p>
      <p data-testid="age">{age}</p>
      <p data-testid="email">{email}</p>
    </section>
  );
};

ProfileCardShallow is the same component with one wrapper added. useShallow memoizes the selector's output and swaps the equality check from Object.is to a key-by-key shallow compare. When the upstream slice has identical primitive values for name, age, and email, the wrapper returns the cached object reference and Zustand sees no change — the re-render never gets scheduled.

test("does NOT re-render when an unrelated field flips — shallow equality short-circuits", () => {
  const root = mountShallow();
  expect(getShallowRenderCount()).toBe(1);

  act(() => {
    useProfileStore.getState().setNotifications(false);
  });
  expect(getShallowRenderCount()).toBe(1);

  act(() => {
    useProfileStore.getState().setTheme("dark");
  });
  expect(getShallowRenderCount()).toBe(1);
  root.unmount();
});

The headline single-component assertion is the case above: after a notifications flip and a theme toggle, the shallow counter is still 1. The same shape carries over to a setName("Ada") no-op, which also holds the counter at 1 because shallow equality sees identical keys and values. Together these cases rule out both unrelated-field bleed and same-value rerenders as silent regressions.

test("five unrelated mutations: naive re-renders, useShallow stays flat", () => {
  const naiveRoot = mountNaive();
  const shallowRoot = mountShallow();

  expect(getNaiveRenderCount()).toBe(1);
  expect(getShallowRenderCount()).toBe(1);

  act(() => {
    useProfileStore.getState().setNotifications(false);
    useProfileStore.getState().setTheme("dark");
    useProfileStore.getState().setNotifications(true);
    useProfileStore.getState().setTheme("light");
    useProfileStore.getState().setNotifications(false);
  });

  const naiveRenders = getNaiveRenderCount() - 1;
  const shallowRenders = getShallowRenderCount() - 1;

  expect(shallowRenders).toBe(0);
  expect(naiveRenders).toBeGreaterThanOrEqual(1);
  expect(naiveRenders).toBeGreaterThan(shallowRenders);

  naiveRoot.unmount();
  shallowRoot.unmount();
});

The cross-component test batches the same five-mutation storm from step 3 and computes a delta from the baseline for each component. The useShallow consumer records exactly zero extra renders, and the naive consumer records at least one. The lower bound is toBeGreaterThanOrEqual(1) rather than toBe(5) because React may batch consecutive mutations inside one act callback into a single commit — the assertion targets the qualitative gap between the two patterns, not React's batching policy.

A complementary test mutates a field the selector actually reads and asserts that both counters tick up by exactly one. That guarantee is what stops a reader from concluding "useShallow skips renders entirely" — it skips only the renders where every selected key compares equal, and it still propagates real changes through immediately.

Verification

bun test src/ProfileCardShallow.test.tsx
bun test v1.2.19 (aad3abea)

 8 pass
 0 fail
 27 expect() calls
Ran 8 tests across 1 file. [646.00ms]

All eight cases pass across the three describe blocks: two for the naive component, four for the useShallow component, and two for the side-by-side comparison. The notifications and theme flips that re-rendered the naive component now leave the shallow component's counter pinned at 1, while the setName("Linus") case still bumps both counters to 2 — exactly the asymmetry we wanted.

What we built

We introduced a ProfileCardShallow component that reads three fields as a single object without paying the re-render cost of a naive object selector. The same module also keeps the broken ProfileCardObjectNaive available as a reference, so the suite can prove the win in one process instead of forcing the reader to imagine a counter-factual.

The new suite encodes three classes of guarantee specific to multi-field selectors. Unrelated-field mutations produce zero extra renders, same-value sets to a selected field short-circuit through shallow equality, and any genuine change to one of the selected fields still bumps the counter exactly once. Together those three properties replace the "object selectors are slow" folklore with a measurable contract.

The cross-component test is the headline result for this step. Under the same five-mutation storm that drove the naive object selector to at least one extra render, the useShallow component records a delta of zero — and the symmetric per-field test confirms both consumers receive exactly one update when a selected field actually changes. The qualitative gap between the two is now a hard CI assertion, not an anecdote.

What this step deliberately leaves on the table is component-level memoization with React.memo, the question of when to extract a custom hook around useShallow, and the relative performance of useShallow versus a custom equality function. Those refinements only matter once the basic subscription shape is correct, and the suite we ship here is what makes them worth considering at all.

Repository

The state of the code after this step: b1bb499

Step 5: Pinning Down Three Selector Equality Strategies on One Render-Counter Probe

The previous four steps moved a single component from a whole-store subscription, through per-field selectors, and finally to useShallow for an object selector. Each step grew its own private pair of render counters and its own get/reset helpers, which was fine in isolation but is now starting to repeat itself across files. This step does two jobs at once: it factors the counter pattern into a small reusable utility, then uses that utility to put three different equality strategies — Object.is, useShallow, and a custom useStoreWithEqualityFn — on the same probe so their semantics can be compared as hard assertions instead of paragraphs of prose.

The reason for stacking three strategies in one suite is that the previous steps left the reader with a binary picture: either the default Object.is check bleeds, or useShallow fixes it. That picture is incomplete. There is a third lever Zustand exposes — a custom equality function passed via useStoreWithEqualityFn — and there is a useful invariant about useShallow itself that needs nailing down with an identity probe: across an unrelated mutation it returns the same object reference, not a freshly-allocated equal-by-value copy.

Setup

The runtime dependency surface does not change. zustand already exposes zustand/traditional (for useStoreWithEqualityFn) and zustand/react/shallow (for useShallow) from the version installed back in step 1, and react-test-renderer is still the headless driver. No bun install step is needed for this step.

Four new files appear under codebase/src/. renderCounter.ts and its companion renderCounter.test.ts host the reusable counter factory and lock in its contract. EqualitySemantics.tsx and EqualitySemantics.test.tsx host four components — one per equality strategy plus an identity probe — and a six-describe suite. The components from steps 2, 3, and 4 stay untouched; this step adds a new lane rather than rewriting the old ones.

Implementation

We start by lifting the counter pattern out of the per-component module scope where it lived in steps 2-4. The shape is a closure that captures a single number and exposes the three operations every previous step needed: bump, get, reset.

export interface RenderCounter {
  bump: () => void;
  get: () => number;
  reset: () => void;
}

export const createRenderCounter = (): RenderCounter => {
  let count = 0;
  return {
    bump: () => {
      count += 1;
    },
    get: () => count,
    reset: () => {
      count = 0;
    },
  };
};

The factory returns a fresh closure on every call, so two counters created by two createRenderCounter() invocations are fully independent — there is no shared module-level state to leak between them. A small renderCounter.test.ts pins this contract with four cases: starts at zero, bumps monotonically, resets to zero without disturbing other counters, and each factory call yields an independent instance. Locking these properties first means the rest of this step can treat the counters as a primitive rather than re-deriving the invariants every time a new component needs one.

Next we declare the four counters and the shared slice selector. Having every counter and the slice projector in one place keeps the four equality strategies on a level playing field — the only difference between them downstream is the equality function passed to the store hook.

export const objectIsCounter = createRenderCounter();
export const shallowCounter = createRenderCounter();
export const customEqCounter = createRenderCounter();
export const shallowIdentityCounter = createRenderCounter();

const selectSlice = (s: ProfileState): ProfileSlice => ({
  name: s.name,
  age: s.age,
  email: s.email,
});

export const nameOnlyEqual = (a: ProfileSlice, b: ProfileSlice): boolean =>
  a.name === b.name;

selectSlice is the same multi-field projection used in step 4. nameOnlyEqual is the custom equality function for the third strategy — it deliberately ignores age and email, so any mutation that does not change name is filtered out at the subscription layer. Exporting it lets the test suite assert the function in isolation before we ever mount a component that uses it.

export const ProfileCardObjectIs = (): JSX.Element => {
  const { name, age, email } = useProfileStore(selectSlice);
  objectIsCounter.bump();
  return (
    <section aria-label="profile-card-object-is">
      <p data-testid="name">{name}</p>
      <p data-testid="age">{age}</p>
      <p data-testid="email">{email}</p>
    </section>
  );
};

export const ProfileCardShallowEq = (): JSX.Element => {
  const { name, age, email } = useProfileStore(useShallow(selectSlice));
  shallowCounter.bump();
  return (/* same DOM as above, different aria-label */);
};

export const ProfileCardNameOnlyEq = (): JSX.Element => {
  const { name, age, email } = useStoreWithEqualityFn(
    useProfileStore,
    selectSlice,
    nameOnlyEqual,
  );
  customEqCounter.bump();
  return (/* same DOM, different aria-label */);
};

The three components share the same selector and the same rendered output; only the subscription path differs. ProfileCardObjectIs is the baseline — Zustand's default Object.is check against a fresh object literal, which we expect to re-render on every store mutation. ProfileCardShallowEq wraps the same selector in useShallow for key-by-key comparison. ProfileCardNameOnlyEq reaches into zustand/traditional for useStoreWithEqualityFn, which accepts an arbitrary equality predicate — here, name-only — letting us prove that a custom predicate can be even stricter than shallow equality.

export const ShallowSliceIdentity = (): JSX.Element => {
  const slice = useProfileStore(useShallow(selectSlice));
  const firstRef = useRef<ProfileSlice | null>(null);
  if (firstRef.current === null) {
    firstRef.current = slice;
    sliceIdentityProbe.firstRef = slice;
  }
  sliceIdentityProbe.latestRef = slice;
  sliceIdentityProbe.sameRef = firstRef.current === slice;
  shallowIdentityCounter.bump();
  return (/* renders slice.name */);
};

The identity probe is the new idea this step contributes. It captures the very first slice reference into a useRef on initial mount, then exposes both the first and latest references through a small module-level object so the test can assert Object.is(first, latest) from the outside. This is the test that distinguishes "useShallow skipped the re-render" from "useShallow re-rendered with an equal-by-value copy" — the second behavior would break any downstream React.memo consumer that depends on prop identity.

test("re-renders on every store mutation because the selector returns a fresh object literal", () => {
  const root = mount(<ProfileCardObjectIs />);
  expect(objectIsCounter.get()).toBe(1);
  act(() => { useProfileStore.getState().setNotifications(false); });
  expect(objectIsCounter.get()).toBe(2);
  act(() => { useProfileStore.getState().setTheme("dark"); });
  expect(objectIsCounter.get()).toBe(3);
  act(() => { useProfileStore.getState().setName("Linus"); });
  expect(objectIsCounter.get()).toBe(4);
  root.unmount();
});

The Object.is baseline runs three unrelated-looking mutations and asserts the counter advances by exactly one each time. This pins the bleed at +3 for three writes rather than handing the bound off to toBeGreaterThanOrEqual — because each mutation is in its own act callback, React commits them separately and the assertion can stay exact. The point is to make the failure of Object.is on multi-field selectors visible and unambiguous, not approximate.

test("returns the same object reference across an unrelated mutation", () => {
  const root = mount(<ShallowSliceIdentity />);
  const initialRef = sliceIdentityProbe.firstRef;
  act(() => { useProfileStore.getState().setNotifications(false); });
  expect(shallowIdentityCounter.get()).toBe(1);
  expect(sliceIdentityProbe.latestRef).toBe(initialRef);
  expect(sliceIdentityProbe.sameRef).toBe(true);
  root.unmount();
});

test("yields a fresh reference when a selected field actually changes", () => {
  const root = mount(<ShallowSliceIdentity />);
  const initialRef = sliceIdentityProbe.firstRef;
  act(() => { useProfileStore.getState().setName("Linus"); });
  expect(shallowIdentityCounter.get()).toBe(2);
  expect(sliceIdentityProbe.latestRef).not.toBe(initialRef);
  expect(sliceIdentityProbe.sameRef).toBe(false);
});

The two identity cases bookend the useShallow contract. After an unrelated mutation, the latest reference is literally the same object as the first one — toBe(initialRef) uses reference equality, not value equality, so this is a strong statement. After a selected-field mutation, the reference must change, which is the symmetric guarantee: useShallow is allowed to return the cached slice only when every key compares equal, and it must allocate a fresh one otherwise. Both halves together rule out the two ways an equality wrapper can quietly misbehave.

test("Object.is bleeds, useShallow stays flat, name-only equality filters everything but name", () => {
  // mount all three, then in one act():
  //   setNotifications(false); setTheme("dark"); setAge(85); setEmail("linus@example.com");
  // assert objectIsDelta and shallowDelta both >= 1, customDelta === 0,
  // then setName("Linus") and assert customDelta bumps by exactly one.
});

The cross-component test mounts all three subscription patterns and drives them through the same four-mutation storm. Object.is bleeds because the selector returns a fresh literal; useShallow re-renders too in this particular storm because setAge and setEmail are inside the selector's read set, so its delta is at least one; the name-only custom equality filters all four out because none of them changed name. A follow-up setName("Linus") then bumps the custom counter by exactly one — proof that the strictest equality is not silently swallowing real updates either.

Verification

bun test src/renderCounter.test.ts src/EqualitySemantics.test.tsx
bun test v1.2.19 (aad3abea)

 4 pass
 0 fail
 8 expect() calls
Ran 4 tests across 1 file. [18.00ms]

 9 pass
 0 fail
 38 expect() calls
Ran 9 tests across 1 file. [135.00ms]

The four createRenderCounter cases all pass, locking the factory's invariants. The nine EqualitySemantics cases cover the pure equality function in isolation, each of the three components in its own describe, the two reference-identity cases for useShallow, and the side-by-side storm. Every assertion lands on the expected count, and the suite completes in under 160ms — fast enough to leave in CI on every push without dragging the feedback loop.

What we built

We extracted a createRenderCounter() factory into its own module and replaced the per-file counter pattern with a single primitive that returns independent closures. The factory comes with a four-case test of its own, so any future regression in the counter contract is caught before it can mislead a downstream selector test.

On top of that primitive we built four parallel components — one per equality strategy plus a reference-identity probe — and a six-describe suite. The suite proves that the default Object.is check bleeds on multi-field selectors, that useShallow returns the same object reference when no selected key changed, that it returns a fresh reference the moment a selected key does change, and that a custom equality function can be strictly narrower than shallow equality without losing real updates.

The cross-component storm is the headline result. Object.is records at least one extra render under the same four-mutation storm in which useShallow also records at least one (because two of the four mutations are inside its read set), while the name-only custom equality records zero — and a follow-up setName bumps only the custom counter, exactly once. The three deltas are now three numbers in one test instead of three claims spread across three commits.

What this step deliberately leaves on the table is performance: we did not measure the wall-clock cost of useShallow's key-by-key compare versus a single-=== custom predicate, and we did not benchmark either against Object.is. Those numbers only matter once the equality semantics are correct, and the suite we ship here is what gives a future benchmark something honest to compare against.

Repository

The state of the code after this step: 73b80cc

Step 6: Mapping the Edges of useShallow — Nested Refs, Derived Shapes, and Primitive Selectors

Steps 2 through 5 walked the happy path: a single-level object selector wrapped in useShallow cuts the re-render storm caused by Zustand's default Object.is check. That picture is honest but incomplete. useShallow compares only the top level of the returned object, so a selector that hides a nested reference inside its slice quietly defeats it; a selector that derives fresh primitives at every call behaves better than people expect; and a selector that returns a primitive directly does not need useShallow at all.

This step turns each of those edges into a small component, instruments it with a render counter, and pins the behavior down with a test. The point is not to scare anyone off useShallow, but to make its contract concrete enough that the reader can decide when to reach for it and when to flatten the selector or drop the wrapper entirely.

Setup

The runtime dependency surface is unchanged from step 5. zustand, react, react-test-renderer, and the TypeScript toolchain installed in step 1 already cover everything in this step — no bun install is needed.

Two new files appear under codebase/src/. EdgeCases.tsx hosts six small components — two for the nested-reference contrast, two for the derived-selector contrast, and two for the primitive contrast — plus six independent render counters created from the createRenderCounter() factory introduced in step 5. EdgeCases.test.tsx hosts a five-describe Bun suite that drives each pair through a targeted mutation script. The existing modules from steps 2 through 5 are not touched; this step adds a new lane rather than rewriting the old ones.

Implementation

We start with the nested-reference case. The store from step 1 exposes a preferences field whose value is itself an object — { theme, notifications } — and the setters always replace that nested object with a fresh reference. A selector that projects { prefs: state.preferences } therefore returns a shape whose only key, prefs, points at an object that changes identity on every preference write.

const selectNestedPrefs = (s: ProfileState): NestedPrefsView => ({
  prefs: s.preferences,
});

export const NestedRefPrefsCard = (): JSX.Element => {
  const { prefs } = useProfileStore(useShallow(selectNestedPrefs));
  nestedRefCounter.bump();
  return (
    <section aria-label="nested-ref-prefs">
      <p data-testid="theme">{prefs.theme}</p>
      <p data-testid="notifications">{prefs.notifications ? "on" : "off"}</p>
    </section>
  );
};

useShallow walks the top-level keys of the returned object and compares each key with Object.is. Here there is only one key — prefs — and its value is the nested object reference, which changes every time a setter runs. The wrapper therefore reports "not equal" on every preference write even when the underlying primitive values are unchanged, and the component re-renders. This is the trap: the selector looks like it benefits from shallow comparison, but the comparison is happening one level too high.

The flat counterpart projects the primitive fields directly, dodging the nested reference entirely.

const selectFlatPrefs = (s: ProfileState): FlatPrefsView => ({
  theme: s.preferences.theme,
  notifications: s.preferences.notifications,
});

export const FlatPrefsCard = (): JSX.Element => {
  const { theme, notifications } = useProfileStore(useShallow(selectFlatPrefs));
  flatPrefsCounter.bump();
  return (/* same DOM, different aria-label */);
};

selectFlatPrefs returns { theme, notifications } — two strings, no nested object reference. useShallow now compares two primitives instead of one reference, and a setter call that lands on the same value (for example, setNotifications(true) when notifications is already true) is filtered out. This is the practical lesson: when you want useShallow to work against a nested slice, flatten the primitives you need into the returned shape rather than passing the nested object through.

The derived-primitives case is the surprise direction. Each call to the selector allocates a fresh object literal whose fields are computed strings, but useShallow still suppresses re-renders correctly as long as those fields are primitives.

const selectDerivedPrimitives = (s: ProfileState): DerivedPrimitivesView => ({
  upperName: s.name.toUpperCase(),
  ageString: String(s.age),
});

export const DerivedPrimitivesCard = (): JSX.Element => {
  const { upperName, ageString } = useProfileStore(
    useShallow(selectDerivedPrimitives),
  );
  derivedPrimitivesCounter.bump();
  return (/* renders upperName + ageString */);
};

Reaching for .toUpperCase() and String(...) inside a selector worries people because it sounds like the selector is allocating on every call. It is — but useShallow does not care about reference identity at the top level, it cares about per-key primitive equality. "ADA".toUpperCase() returns the same string "ADA" on every call until state.name changes, and a string === compare on two equal strings short-circuits to true. The selector being "pure" in the functional sense is enough; the wrapper handles the allocation churn.

The derived-with-nested-ref case is the same shape as the flat-derived case but smuggles the preferences object through. The contrast is the point.

const selectDerivedWithNestedRef = (s: ProfileState): DerivedNestedRefView => ({
  name: s.name,
  prefs: s.preferences,
});

export const DerivedNestedRefCard = (): JSX.Element => {
  const { name, prefs } = useProfileStore(useShallow(selectDerivedWithNestedRef));
  derivedNestedRefCounter.bump();
  return (/* renders name + prefs.theme */);
};

name is a primitive, but prefs is the live nested reference from the store. On any preference write, the prefs key in the returned shape points at a fresh object, so useShallow sees the per-key compare fail and re-renders the component — even when the value of prefs.theme is unchanged from the previous render. This is the same trap as NestedRefPrefsCard, just packaged inside a multi-field selector where it is easier to miss during review.

Finally, the single-primitive case: a selector that returns state.name directly, with and without useShallow, to show that the wrapper is a no-op here.

const selectName = (s: ProfileState): string => s.name;

export const SinglePrimitivePlainCard = (): JSX.Element => {
  const name = useProfileStore(selectName);
  singlePlainCounter.bump();
  return (/* renders name */);
};

export const SinglePrimitiveShallowCard = (): JSX.Element => {
  const name = useProfileStore(useShallow(selectName));
  singleShallowCounter.bump();
  return (/* same DOM, different aria-label */);
};

Zustand's default Object.is check already handles primitives correctly: two equal strings compare equal, two different strings compare not equal, and the component subscribes only to changes in name. Wrapping the same selector in useShallow adds an iteration over the (non-existent) keys of a primitive and changes nothing observable. The tests below assert that both counters move in lockstep across an identical mutation script, which is the cleanest way to say "do not reach for this wrapper when you do not need it."

The test suite drives one mutation script per pair. For the nested-ref pair it asserts that NestedRefPrefsCard — the one that passes preferences through as a single key — re-renders on every preference write, while FlatPrefsCard stays flat against same-value writes and against name-only writes. For the derived pair it asserts that DerivedPrimitivesCard is silent across unrelated writes but bumps once per relevant write, while DerivedNestedRefCard bleeds on preference writes where the flat one does not. For the primitive pair it asserts that the plain and shallow-wrapped components produce identical counts across a four-mutation storm, a name change, and a same-value name re-write.

Verification

bun test src/EdgeCases.test.tsx
bun test v1.2.19 (aad3abea)

 9 pass
 0 fail
 47 expect() calls
Ran 9 tests across 1 file. [129.00ms]

All nine cases pass. Two of them are the per-pair side-by-side mutation storms — nestedDelta > flatDelta for the nested-vs-flat contrast and nestedDelta > derivedDelta for the derived contrast — that anchor each edge into a concrete inequality rather than a paragraph. The suite finishes in under 150ms, leaving plenty of budget to keep it in CI on every push.

What we built

We mapped three edges of useShallow into a single file of six components and a five-describe Bun suite. The nested-reference pair shows that useShallow only compares the top level of the returned object, and that the right reaction when a nested object is involved is to flatten the primitives you actually need.

The derived-selector pair shows that allocating fresh objects inside a selector is not the problem people fear — the problem is what those objects contain. A selector that returns a fresh object whose fields are derived primitives plays nicely with useShallow; a selector that returns a fresh object whose fields embed a live nested reference does not.

The single-primitive pair shows the boundary on the other side: when a selector already returns a primitive, the default Object.is check is exactly what you want, and useShallow adds nothing. Watching both counters move in lockstep across the same mutation script makes the "no-op" claim concrete instead of a vague "don't wrap primitives" rule of thumb.

Together these three contrasts turn the article's central tool into a contract rather than a magic word. A reader who reaches step 6 with the earlier steps in hand now has a clear decision rule — flatten nested refs, allow derived primitives, skip the wrapper on primitive selectors — backed by tests that fail loudly if any of those claims stop being true.

Repository

The state of the code after this step: f86992f

Repository

Full source at https://github.com/vytharion/zustand-selector-re-renders-useshallow.

Walk the lessons by stepping through the git commits in the repo — each major step has its own commit you can git checkout and rerun.