Memoizing Selectors With Reselect
A select function turns cached server state into the shape a component actually renders. Because it runs on the render path, an expensive derivation there is paid on every render, not on every cache write — and the usual attempts to fix that (wrapping in useMemo, extracting the function) frequently fail for reasons that are invisible in the code. This page wires Reselect into the pattern properly, as an applied case of Selector & Derivation Patterns. It pairs directly with Avoiding Selector Recomputation on Every Render, which covers the identity rules from the React side, and it assumes the referential-stability model described in Structural Sharing and Referential Stability.
Prerequisites
- A query whose
selectdoes real work — grouping, sorting, joining across entities, or aggregating. reselectv5 installed, which shipscreateSelectorwith a configurable memoization strategy.- A way to count recomputes; a module-level counter is enough to start.
Step 1 — Write the Derivation as a Pure Function
Before memoizing anything, the derivation must be a function of its inputs alone. A selector that reads a ref, the clock or a module variable cannot be memoized correctly.
export interface Todo { id: string; title: string; done: boolean; assigneeId: string | null; updatedAt: string }
export interface User { id: string; name: string }
export interface TodoRow { id: string; title: string; assigneeName: string; overdue: boolean }
/** Pure: same inputs, same output, no ambient reads. */
export function buildTodoRows(todos: Todo[], users: User[], nowIso: string): TodoRow[] {
const usersById = new Map(users.map((user) => [user.id, user]));
return todos
.filter((todo) => !todo.done)
.map((todo) => ({
id: todo.id,
title: todo.title,
assigneeName: todo.assigneeId ? usersById.get(todo.assigneeId)?.name ?? 'Unknown' : 'Unassigned',
// `now` is an argument, not a Date.now() call, so the function stays pure.
overdue: todo.updatedAt < nowIso,
}))
.sort((a, b) => a.title.localeCompare(b.title));
}
Cache Behavior Analysis: Passing nowIso as an argument rather than calling Date.now() inside is what makes memoization possible at all — a selector that reads the clock produces a different result for identical inputs, so any cache around it is either wrong or useless. Building the Map inside the derivation is correct here because the map is derived from users and lives exactly as long as one computation; hoisting it to module scope would create a second, unmanaged cache that nothing invalidates. The sort mutates the array produced by map, not the cached array, which matters because mutating todos in place would corrupt the cache entry that other observers are reading.
Step 2 — Wrap It in createSelector
Input selectors should be cheap and reference-comparable; the result function holds all the cost.
import { createSelector } from 'reselect';
interface SelectorInput { todos: Todo[]; users: User[]; nowIso: string }
export const selectTodoRows = createSelector(
// Input selectors: cheap projections, compared by reference.
[(input: SelectorInput) => input.todos,
(input: SelectorInput) => input.users,
(input: SelectorInput) => input.nowIso],
// Result function: runs only when one of the three inputs changes identity.
buildTodoRows,
);
Cache Behavior Analysis: Reselect compares input-selector results with === by default, which lines up exactly with TanStack Query’s structural sharing: when a refetch returns data that is deeply equal to what is cached, the library reuses the previous object references, so input.todos is referentially identical and the result function is skipped entirely. That is the property that makes the pairing worthwhile — a poll every thirty seconds that returns unchanged data costs zero derivation work. Bucketing nowIso to a coarse granularity (the current minute rather than the current millisecond) keeps that property intact when the derivation genuinely depends on time.
Step 3 — Give Each Parameterisation Its Own Memo Slot
createSelector’s default cache size is one. Two components calling the same selector with different arguments evict each other on every render.
import { createSelector, lruMemoize } from 'reselect';
// Option A: widen the argument cache so alternating callers both hit.
export const selectTodoRowsFor = createSelector(
[(input: SelectorInput) => input.todos,
(input: SelectorInput) => input.users,
(input: SelectorInput) => input.nowIso,
(_input: SelectorInput, assigneeId: string | null) => assigneeId],
(todos, users, nowIso, assigneeId) =>
buildTodoRows(todos, users, nowIso).filter((row) =>
assigneeId === null ? true : row.assigneeName !== 'Unassigned',
),
{ memoizeOptions: { maxSize: 12 }, argsMemoize: lruMemoize, argsMemoizeOptions: { maxSize: 12 } },
);
// Option B: one selector instance per parameter — no shared slot to evict.
const perAssignee = new Map<string, ReturnType<typeof makeAssigneeSelector>>();
function makeAssigneeSelector(assigneeId: string) {
return createSelector(
[(input: SelectorInput) => input.todos, (input: SelectorInput) => input.users,
(input: SelectorInput) => input.nowIso],
(todos, users, nowIso) =>
buildTodoRows(todos, users, nowIso).filter((row) => row.assigneeName !== 'Unassigned'),
);
}
export function selectorForAssignee(assigneeId: string) {
let selector = perAssignee.get(assigneeId);
if (!selector) { selector = makeAssigneeSelector(assigneeId); perAssignee.set(assigneeId, selector); }
return selector;
}
Cache Behavior Analysis: Reselect v5 memoizes twice — once over the arguments passed to the selector (argsMemoize) and once over the results of the input selectors (memoize) — and both default to a size of one, which is why widening only memoizeOptions frequently changes nothing. Option B trades that bookkeeping for an unbounded Map of selector instances, which is fine when the parameter space is small and closed, and a memory leak when it is user-supplied ids. When the parameter is genuinely unbounded, the honest answer is usually that the parameter belongs in the query key instead, so the cache — not the selector — does the splitting.
Step 4 — Verify the Recompute Count
Selector memoization fails silently. The only way to know it is working is to count.
let derivationRuns = 0;
export const selectTodoRowsInstrumented = createSelector(
[(input: SelectorInput) => input.todos,
(input: SelectorInput) => input.users,
(input: SelectorInput) => input.nowIso],
(todos, users, nowIso) => {
derivationRuns += 1;
return buildTodoRows(todos, users, nowIso);
},
);
test('derivation runs once per data change, not once per render', () => {
const input: SelectorInput = { todos: [], users: [], nowIso: '2026-07-31T00:00' };
selectTodoRowsInstrumented(input);
selectTodoRowsInstrumented(input);
selectTodoRowsInstrumented(input);
expect(derivationRuns).toBe(1);
// A new top-level object with the SAME inner references must still hit.
selectTodoRowsInstrumented({ ...input });
expect(derivationRuns).toBe(1);
// Only a genuine data change recomputes.
selectTodoRowsInstrumented({ ...input, todos: [{ id: 't1' } as Todo] });
expect(derivationRuns).toBe(2);
});
Cache Behavior Analysis: The third assertion is the one that catches real bugs: passing a fresh wrapper object with identical inner references must still hit the memo, because the input selectors project down to todos and users before any comparison happens. If that assertion fails, an input selector is returning something newly allocated — a .map(), a spread, an object literal — which makes reference comparison meaningless. Reselect v5 also exposes selector.recomputations() and selector.resetRecomputations(), which are worth using in place of a hand-rolled counter once you have more than one selector under test.
Edge Cases & Gotchas
The selector must be referentially stable in select. Passing selectTodoRows directly is stable because it is module-scope. Passing (data) => selectTodoRows({ ...data, nowIso }) recreates the arrow every render, and TanStack Query then re-runs it even when data is unchanged. Hoist the wrapper or build it with useCallback.
Structural sharing must stay on. structuralSharing: false on a query means every refetch produces entirely new references, so input comparison always misses and the selector recomputes on every poll. Only disable it for data that is not JSON-serializable.
Result identity feeds React.memo. A memoized selector returning the same array reference is what lets a memoized child skip re-rendering. If the derivation produces a new array of equal content each time, the memoization saves CPU in the selector and none in the tree below it.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Selector recomputes on every render | An inline arrow is passed to select, so the function identity changes |
Hoist the selector to module scope or wrap with useCallback |
| Memo misses whenever two components use one selector | Default maxSize of one, evicted by alternating arguments |
Widen both memoizeOptions and argsMemoizeOptions, or use per-parameter instances |
| Memo misses after every poll | structuralSharing is disabled, so references change even when data does not |
Re-enable structural sharing; make the data JSON-serializable |
| Memory grows with the number of parameter values | A Map of per-parameter selector instances is never pruned |
Move the parameter into the query key instead |
| Child components still re-render | The derivation returns a new array of equal content | Compare with a stable identity or memoize the mapped rows individually |
Frequently Asked Questions
Does the select option already memoize for me?
Partly, and the part it does not cover is the part that usually bites. TanStack Query skips the selector when the underlying data is referentially unchanged and the selector function itself is referentially stable — so a module-scope selector over structurally shared data is already skipped between renders. What it does not do is memoize across changes: when data does change, the whole derivation runs, and if the selector is an inline arrow, the stability check fails and it runs on every render regardless. Reselect adds the second layer, and hoisting the function is what makes the first layer work.
Why does my selector recompute even though the data did not change?
Almost always because an argument is newly allocated on each call. createSelector defaults to a cache of size one on both its argument memo and its input memo, so two callers with different arguments — or one caller passing a fresh object literal — evict each other and every call is a miss. The diagnostic is the test in Step 4: pass a fresh wrapper object with identical inner references and assert the recompute count did not move. If it did, an input selector is allocating.
Is Reselect worth adding for a single filter?
No. A single pass over a few hundred items costs microseconds, and the memoization bookkeeping plus the extra indirection is genuinely more code than the filter. Reselect starts paying when the derivation is expensive in its own right — joins across several entity collections, sorting thousands of rows, building lookup structures — or when the identity of the result matters because it is the prop of a memoized subtree. If neither is true, an inline filter in select is the correct engineering choice.
Related
- Selector & Derivation Patterns — the parent guide on deriving render-ready views from cached state.
- Avoiding Selector Recomputation on Every Render — the sibling technique covering identity rules on the React side.
- Deriving Filtered Views From Normalized Cache — building the derivations that this page memoizes.
- Structural Sharing and Referential Stability — why cached references survive a refetch, which is what makes memoization effective.