open atlas
↑ Back to track
React patterns, senior RXP · 07 · 02

Selectors and tearing

A selector subscribes a component to only the store slice it reads, so unrelated changes skip its render; read through the store hook to avoid tearing, and give shallow equality to any selector that builds a fresh object/array — or it re-renders on every change.

RXP Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

An external store holds everything: the cart, the user, a dozen feature flags, the open/closed state of a sidebar. Toggle the sidebar and — if every component just reads “the store” — every component re-renders. A store that grows this way doesn’t scale: it turns one tiny mutation into a tree-wide render storm, and the bigger the app, the worse it gets.

The fix is a selector: a function that pulls out only the slice a component needs, so the component re-renders when that slice changes and ignores everything else. Get the selector right and the store stays fast at any size. Get its equality wrong — return a fresh object every call — and you build a component that re-renders on every store change, the exact storm you were trying to stop.

Goal

After this lesson you can write a selector that subscribes a component to a single store slice; explain why you read through the store’s official hook rather than a raw module variable (tearing under concurrent rendering); recognize the new-object-each-render equality pitfall that silently defeats selector memoization; and fix it with a shallow-equality comparator — while knowing when a selector is fine to leave un-memoized.

1

A selector is the subscription. Read a slice, and you re-render only when that slice changes. A store exposes a hook that takes a selector function; the hook runs the selector, remembers the result, and re-runs your component only when the selected value changes by the store’s equality check. Reading the whole store object instead means any change anywhere wakes you up.

// store.ts (Zustand-style; the shape is the same for any selector store)
const useCart = create<CartState>((set) => ({
  items: [],
  couponOpen: false,
  addItem: (i) => set((s) => ({ items: [...s.items, i] })),
}));

// reads ONE field → re-renders only when items.length changes
function CartBadge() {
  const count = useCart((s) => s.items.length);
  return <span>Cart ({count})</span>;
}

CartBadge is now blind to couponOpen, to coupon edits, to anything but the item count. That selectivity is the pattern — without it the badge would re-render every time anyone touched any field.

2

Read through the store’s hook, never a raw module variable — that is what protects you from tearing. “Tearing” is when one render pass reads two different values of the same state because the value changed mid-render. React 18+ renders concurrently: it can pause, yield, and resume a render. If a component reads a plain mutable module variable directly, an update between yield and resume produces a render where part of the tree saw the old value and part saw the new — a torn, internally inconsistent UI.

// ❌ raw read — no subscription, and tears under concurrent rendering
let cartModule = { items: [] as Item[] };
function Bad() {
  return <span>{cartModule.items.length}</span>; // stale + tearing risk
}

// ✅ the store hook is built on useSyncExternalStore, which guarantees
// every read in a render pass sees one consistent snapshot
function Good() {
  const count = useCart((s) => s.items.length);
  return <span>{count}</span>;
}

The store’s hook is built on useSyncExternalStore, whose whole job is to give React a single consistent snapshot per render and force a synchronous re-render if the external value changed during a concurrent pass. That is why the rule “use the hook, not the variable” exists — it is not style, it is correctness.

3

The pitfall: a selector that builds a fresh object or array each call breaks memoization and re-renders forever. The store decides “did the selected value change?” with a reference comparison (Object.is) by default. A selector returning { a, b } or [x, y] constructs a brand-new reference on every run, so the comparison is always “changed”, so the component re-renders on every store update — the failure mode this whole lesson exists to prevent.

// ❌ fresh object every call → Object.is(prev, next) is always false
// → re-renders on EVERY store change, even unrelated ones
function CartSummary() {
  const { count, total } = useCart((s) => ({
    count: s.items.length,
    total: s.items.reduce((n, i) => n + i.price, 0),
  }));
  return <p>{count} items · ${total}</p>;
}

It looks correct and renders the right numbers — which is why it survives review. The bug is silent: it’s not wrong output, it’s wasted renders, invisible until a profiler shows this component flashing on every keystroke elsewhere in the app.

4

The fix: tell the store to compare the selected value by shallow equality, or select primitives. Shallow-equal compares each top-level key/element by reference instead of comparing the container reference. Now { count: 2, total: 40 } equals last render’s { count: 2, total: 40 }, so no re-render fires unless a field actually changes.

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

// ✅ shallow equality on the returned object → re-renders only when
// count or total actually changes
function CartSummary() {
  const { count, total } = useCart(
    useShallow((s) => ({
      count: s.items.length,
      total: s.items.reduce((n, i) => n + i.price, 0),
    })),
  );
  return <p>{count} items · ${total}</p>;
}

// ✅ or sidestep it entirely: select two primitives in two hooks
function CartSummaryPrimitive() {
  const count = useCart((s) => s.items.length);
  const total = useCart((s) => s.items.reduce((n, i) => n + i.price, 0));
  return <p>{count} items · ${total}</p>;
}

Rule of thumb: a selector returning a primitive (number, string, boolean) needs no equality function — Object.is already does the right thing. A selector returning a fresh object or array must be paired with a shallow-equality comparator, or it defeats the very memoization the selector exists to provide.

Worked example

One component, fixed by changing only the selector. A header shows the signed-in user’s name and avatar from a store that also holds a fast-changing field — say a cursorPosition updated on every mouse move for a collaboration feature.

Before — selecting a fresh object with no equality function:

function UserChip() {
  // fresh object every render; cursorPosition fires constantly,
  // and because Object.is is always false, this re-renders on
  // every mouse move even though name/avatar never change
  const { name, avatar } = useApp((s) => ({
    name: s.user.name,
    avatar: s.user.avatarUrl,
  }));
  return (
    <span>
      <img src={avatar} alt="" /> {name}
    </span>
  );
}

In a profiler, UserChip flashes on every cursor update — dozens of renders per second for a component whose data is static for the whole session. The output is correct; the cost is hidden.

After — same shape, plus shallow equality:

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

function UserChip() {
  const { name, avatar } = useApp(
    useShallow((s) => ({ name: s.user.name, avatar: s.user.avatarUrl })),
  );
  return (
    <span>
      <img src={avatar} alt="" /> {name}
    </span>
  );
}

Now shallow-equal compares name and avatar by reference; both are unchanged on a cursor move, so UserChip skips the render entirely. It re-renders only when the name or avatar actually changes. The diff is one import and one wrapper — but it converts a component that rendered hundreds of times per minute into one that renders only when its data does. That is the difference between a store that scales and one that drowns the app in renders.

Why this works

Why does the store compare the selected value and not the whole store? Because the selector is the boundary. The store can’t know which fields you care about — only your selector expresses that. So it memoizes on the selector’s return value: if that value is “the same” by the equality function, your component is told nothing changed. This is exactly why the equality function matters so much. The selector says what you read; the equality function says what counts as a change to it. Get either wrong and the subscription is wrong: too-broad a selector subscribes you to noise; a too-loose equality (a fresh reference) reports change on every tick.

Common mistake

Don’t reflexively reach for useMemo to fix the fresh-object problem — it doesn’t fix it. useMemo memoizes a value within one component’s render, but the selector runs inside the store’s subscription, comparing across renders. The store needs an equality function (useShallow, or a custom comparator), not a memoized selector. The opposite over-correction is just as common: wrapping every primitive selector in useShallow. A selector returning a single number or string already compares correctly with Object.is — adding shallow equality there is pure noise. Reach for shallow equality precisely when, and only when, the selector returns a fresh object or array.

Check yourself
Quiz

A teammate writes useStore((s) => ({ id: s.user.id, plan: s.user.plan })) and the component re-renders on every unrelated store update, even though id and plan never change. What is the cause and the right fix?

Recap

A selector is how a component subscribes to a store: it reads a single slice, so the component re-renders only when that slice changes and ignores every unrelated mutation — which is the entire reason stores stay fast as they grow. Always read through the store’s hook (built on useSyncExternalStore), never a raw module variable, because the hook guarantees a single consistent snapshot per render and prevents tearing under concurrent rendering. The signature failure mode: a selector that builds a fresh object or array each call defeats the store’s reference-equality check, so the component re-renders on every store update — silently, because the output is still correct. Fix it by selecting primitives (which compare correctly under Object.is) or by passing a shallow-equality function (useShallow) when you must return a composite. The discipline is small and exact: primitive selectors need no comparator; composite selectors require one — and useMemo is not it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.