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 select does real work — grouping, sorting, joining across entities, or aggregating.
  • reselect v5 installed, which ships createSelector with a configurable memoization strategy.
  • A way to count recomputes; a module-level counter is enough to start.
Where memoization can and cannot help A cache write produces a new data reference which flows into input selectors and then the result function. Renders that do not follow a cache write reach the memoized result directly. A third path shows an unstable argument bypassing the memo and forcing a recompute on every render. Two paths reach the result; only one of them recomputes cache write new data reference input selectors cheap, reference compare result function the expensive pass memo plain re-render data unchanged reads the memo — no work at all unstable argument new object each render cache size 1 — evicts itself, recomputes every render
The failure is not that memoization is missing; it is that the memo is being evicted between every pair of calls.

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.

Derivation runs during 60 seconds of typing in an unrelated field Bar chart of how many times the derivation executed. An inline select function ran 148 times, a hoisted unmemoized function 148 times, useMemo in the component 148 times, and a Reselect selector ran twice. Derivation runs during 60 seconds of typing in an unrelated field 1,200-item list, one poll landing during the window inline arrow in select 148 runs hoisted plain function 148 runs useMemo on the result 148 runs createSelector 2 runs
Hoisting the function changes nothing on its own — the selector still runs per render. Only memoization keyed on data identity collapses it to one run per cache write.

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.

What happens on each of four consecutive renders Timeline of four renders. The first performs the full derivation. The second, triggered by unrelated state, hits the memo. The third follows a poll returning unchanged data and hits the memo because structural sharing preserved references. The fourth follows a real mutation and recomputes. What happens on each of four consecutive renders render 1 Cold — full derivation 1,200 items grouped and sorted render 2 Unrelated state changed memo hit, zero work render 3 Poll returned same data references preserved, memo hit render 4 Mutation changed one row recompute, new result identity
Renders two and three are free. Structural sharing is what makes render three free, and it only works if the selector compares references rather than values.

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.