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 fromdatain 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.
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.
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.
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.
Related
- Selector & Derivation Patterns — the parent guide on shaping cached state for render.
- Memoizing Selectors With Reselect — the sibling technique for derivations expensive enough to need a memoization library.
- Structural Sharing and Referential Stability — why an unchanged refetch keeps its references, which is what gate three depends on.
- Deriving Filtered Views From Normalized Cache — the derivations these identity rules apply to.