Avoiding Selector Recomputation on Every Render

A select function that looks cheap in isolation becomes expensive when it runs sixty times a second because an unrelated piece of component state is changing. The frustrating part is that the code usually looks correct — the derivation is pure, the data has not changed, and yet the profiler shows the selector at the top. The cause is nearly always identity, not logic. This page covers the four identity rules that govern when a selector actually runs, under Selector & Derivation Patterns. It is the React-side companion to Memoizing Selectors With Reselect, and it leans on the reference model described in Structural Sharing and Referential Stability.

Diagnostic Checklist

Prerequisites

  • A query using select, or a component deriving from data in its body.
  • React DevTools Profiler, or a counter you are willing to add temporarily.
  • Confirmation that the derivation is genuinely pure — see Step 1 of Memoizing Selectors With Reselect.
The four gates between a render and a recompute A render passes through four checks in order: did the observer notify, is the selector function referentially stable, did the data reference change, and did the selected result change identity. Each gate lists the specific mistake that forces the render past it. A render only costs a derivation if it gets past all four gate 1 did the observer notify at all? widen: notifyOnChangeProps gate 2 is the selector fn the same reference? breaks: inline arrow gate 3 did data change reference? breaks: sharing disabled derivation runs the cost gate 4 — after the derivation: did the RESULT change identity? If not, the component does not re-render and memoized children below it are untouched. Fix in order. Gate 2 is one line and resolves most cases. Reaching for Reselect before hoisting the function usually memoizes a function that is being replaced anyway.
Each gate has exactly one common failure. Diagnosing which gate is open takes less time than optimising the derivation.

Step 1 — Make the Selector Referentially Stable

The observer compares the selector by reference. A new function each render means the previous selection is discarded regardless of the data.

import { useQuery } from '@tanstack/react-query';

// ✗ A new function identity on every render — the selection is never reused.
function BadOpenCount({ filters }: { filters: TodoFilters }) {
  const { data } = useQuery({
    queryKey: ['todos', 'list', filters],
    queryFn: fetchTodos,
    select: (todos) => todos.filter((todo) => !todo.done).length,
  });
  return <span>{data}</span>;
}

// ✓ Module scope: one identity for the life of the module.
const selectOpenCount = (todos: Todo[]) => todos.filter((todo) => !todo.done).length;

function GoodOpenCount({ filters }: { filters: TodoFilters }) {
  const { data } = useQuery({
    queryKey: ['todos', 'list', filters],
    queryFn: fetchTodos,
    select: selectOpenCount,
  });
  return <span>{data}</span>;
}

Cache Behavior Analysis: QueryObserver caches the result of select alongside the data it was computed from and reuses it when both the data reference and the selector reference are unchanged. With an inline arrow, the second condition fails on every render, so the derivation runs even when the query has not fetched for minutes — the work is invisible in the network panel and shows up only in the profiler. Hoisting to module scope costs nothing and fixes the majority of “my selector is slow” reports before any memoization library is involved.


Step 2 — Keep Structural Sharing On

Structural sharing is what makes an unchanged poll response referentially identical to the previous one. Disabling it turns every refetch into a full re-derivation.

useQuery({
  queryKey: ['dashboard', 'stats'],
  queryFn: fetchStats,
  refetchInterval: 15_000,
  // Default is true. Leaving it on means a poll that returns identical JSON
  // reuses the previous object graph, so `data` keeps its reference.
  structuralSharing: true,
  select: selectDashboardRows,
});

// The narrow exception: data that is not JSON-serializable.
useQuery({
  queryKey: ['report', 'blob'],
  queryFn: fetchReportBlob,
  // A Blob cannot be structurally compared, so sharing must be off — and the
  // selector must then do its own identity management.
  structuralSharing: false,
});

Cache Behavior Analysis: Structural sharing walks the incoming response against the cached one and reuses every sub-object that is deeply equal, so a poll returning unchanged data produces a data reference that is === to the previous one and gates 3 and 4 both close. It costs one deep comparison per fetch, which on a list of a few thousand plain objects is well under a millisecond — far less than the re-render it prevents. The exception is data containing values the comparison cannot handle: Blob, File, class instances or anything with cyclic references, where sharing must be disabled and identity managed by hand.

Selector executions during a 30-second polling window Line chart across six five-second buckets. An inline selector runs between 40 and 58 times per bucket. A hoisted selector runs between 2 and 3. A hoisted selector with notifyOnChangeProps runs 2 per bucket. Selector executions during a 30-second polling window inline arrow hoisted hoisted + notifyOnChangeProps 0 15 29 44 58 executions 0-5 5-10 10-15 15-20 20-25 25-30 seconds elapsed
The step from inline to hoisted is a single line of code and removes roughly 95% of the executions.

Step 3 — Narrow What Triggers a Notification

By default an observer re-renders when any tracked property changes — including isFetching flipping twice per poll. Narrowing the notification list removes renders you never needed.

function OpenCountBadge() {
  const { data } = useQuery({
    queryKey: ['todos', 'list', {}],
    queryFn: fetchTodos,
    select: selectOpenCount,
    // This component renders a number. It does not show a spinner, so it has
    // no reason to re-render when isFetching toggles during a background poll.
    notifyOnChangeProps: ['data', 'error'],
    refetchInterval: 10_000,
  });

  return <span className="badge">{data ?? 0}</span>;
}

Cache Behavior Analysis: v5 tracks which properties of the query result a component actually reads and only notifies on those, so notifyOnChangeProps is mainly a way to be more restrictive than the automatic tracking — useful when a component destructures a property it uses only in an event handler. Restricting to ['data','error'] means the two isFetching transitions of every background refetch no longer produce renders, which on a ten-second interval is twelve avoided renders a minute per observer. This reduces the renders that would have called the selector; it does nothing about the selector running when the cache genuinely writes, which is what Step 1 and memoization handle.


Step 4 — Return a Stable Identity

A selector that returns a fresh object or array each run gives every consumer a new prop identity, so React.memo below it never skips.

// ✗ New object every run — even when both numbers are unchanged.
const selectSummaryBad = (todos: Todo[]) => ({
  open: todos.filter((t) => !t.done).length,
  done: todos.filter((t) => t.done).length,
});

// ✓ Primitive results compare by value, so identity is free.
const selectOpen = (todos: Todo[]) => todos.filter((t) => !t.done).length;
const selectDone = (todos: Todo[]) => todos.filter((t) => t.done).length;

function Summary() {
  const { data: open } = useQuery({ queryKey: ['todos', 'list', {}], queryFn: fetchTodos, select: selectOpen });
  const { data: done } = useQuery({ queryKey: ['todos', 'list', {}], queryFn: fetchTodos, select: selectDone });
  // Two observers over ONE cache entry: one fetch, one stored response,
  // two independently memoized selections.
  return <SummaryView open={open ?? 0} done={done ?? 0} />;
}

const SummaryView = React.memo(function SummaryView({ open, done }: { open: number; done: number }) {
  return <p>{open} open · {done} done</p>;
});

Cache Behavior Analysis: Two useQuery calls with the same key create two observers over a single cache entry — there is one fetch, one stored response and one gcTime timer, with each observer memoizing its own selection independently. Splitting an object-returning selector into per-field selectors is therefore free in cache terms and turns identity comparison from reference equality into value equality, which is what allows React.memo to work. When the result genuinely must be an object, memoize it as described in Memoizing Selectors With Reselect so the same reference is returned for unchanged inputs.

Symptom to gate, and the one-line fix Matrix mapping four symptoms to which of the four gates is open and the corrective change. Symptom to gate, and the one-line fix Gate Fix Selector runs while unrelated state changes Gate 2 — selector identity Hoist the function to module scope Selector runs after every poll of unchanged data Gate 3 — data identity Re-enable structuralSharing Component re-renders twice per background refetch Gate 1 — notification breadth notifyOnChangeProps: ['data','error'] Memoized children re-render despite equal values Gate 4 — result identity Return primitives, or memoize the object
Work top to bottom. Each row's fix is cheaper than the one below it.

Edge Cases & Gotchas

Selectors that close over props. select: (data) => pick(data, columnIds) cannot be hoisted because it needs columnIds. Wrap it in useCallback with columnIds as a dependency, and make sure columnIds is itself stable — a fresh array literal in the parent defeats the whole chain.

Suspense queries. useSuspenseQuery applies the same selector rules, but a selector that throws now unmounts the boundary rather than populating error. Keep selectors total: return an empty result rather than throwing on unexpected shapes.

Derivation moved out of select. Deriving in the component body with useMemo looks equivalent but runs after the render has already been scheduled, so it cannot prevent the render — only shorten it. select can prevent the render entirely, because an unchanged selection means the observer does not notify.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Selector appears in the profiler on unrelated renders An inline arrow is passed to select Hoist to module scope, or useCallback when it closes over props
Every poll re-renders the whole subtree structuralSharing is disabled, so data is a new reference each fetch Re-enable it; if the data is not serializable, memoize the selection instead
Two extra renders per background refetch The observer notifies on isFetching changes the component does not use Set notifyOnChangeProps: ['data','error']
React.memo never skips The selector returns a fresh object each run Split into primitive selectors or memoize the object identity
useCallback selector still unstable A dependency of the callback is itself recreated each render Stabilise the dependency first — usually an array or object literal in the parent

Frequently Asked Questions

Why does an inline select function defeat memoization?

Because the observer decides whether it can reuse the previous selection by comparing both the data reference and the selector reference. An arrow function written inside the component body is a newly allocated function on every render, so the second comparison always fails and the selection is recomputed — even when the query has not fetched in ten minutes and the data object is byte-for-byte the one from last time. This is why the fix is hoisting rather than adding a memoization library: memoizing a function that is itself being replaced every render solves nothing.

Does notifyOnChangeProps reduce selector work?

Indirectly, and only for one class of renders. It reduces the number of times the observer notifies React, which reduces the number of renders, which reduces the number of selector calls those renders would have made. It does nothing about a selector running when the cache actually writes new data — that path goes through the observer regardless of what it notifies on. The two mechanisms address different halves of the problem, and a component that both polls frequently and derives expensively needs both.

Should I return a new object from select for convenience?

Only if you memoize it. A select returning { open, done } produces a new reference on every run, so anything downstream comparing by identity — React.memo, a useEffect dependency array, a context value — sees a change even when both numbers are unchanged. The cheapest fix is usually to split it into two selectors over the same query key: that costs nothing in cache terms, because both observers read one entry, and it turns identity comparison into value comparison for free.