skip to content

An NgRx 22 invoicing dashboard's per-customer view recomputes after almost every dispatched action; what defeats createSelector's memoization, and how do you fix it?

level: seniorimportance: should knowfreq 35%

answer

  1. references, not contents
  2. inputs that build new arrays
  3. one cache entry per instance
  4. factory called on every read
  5. resultMemoize with a comparator

basics

~20 s

createSelector caches one set of arguments compared by ===. It misses when an input selector builds a new array or object each call, when one instance is shared across different parameters, or when a factory creates a fresh selector per read.

solid answer

~50 s

A `createSelector` selector keeps only its last arguments, compared with `===`. The usual culprit is an input selector that derives, such as `(state) => state.invoices.items.filter(...)`: it returns a new array on every call, so after any action that changes any slice the projector sees a new argument and reruns. Keep inputs as plain property reads, which return stored references, and move filtering into the projector. Second, one instance shared by readers passing different parameters thrashes its single cache entry; give each reader its own instance from a factory selector. Third, calling that factory inside a template method or getter creates a new selector with an empty cache on every read; create it once per component. Finally, a projector that returns a new array makes downstream selectors and views see a change whenever it reruns; `createSelectorFactory` with `resultMemoize` and a content comparator keeps the old reference.

code

ts · 17 lines
ts
import { createSelectorFactory, resultMemoize } from '@ngrx/store';
import { selectInvoices, selectSelectedCustomerId } from './invoices.selectors';
import { Invoice } from './invoice.model';

const sameInvoices = (a: Invoice[], b: Invoice[]) =>
  a.length === b.length && a.every((inv, i) => inv === b[i]);

const createListSelector = createSelectorFactory((projector) =>
  resultMemoize(projector, sameInvoices)
);

export const selectCustomerInvoices = createListSelector(
  selectInvoices,
  selectSelectedCustomerId,
  (invoices: Invoice[], customerId: string | null) =>
    invoices.filter((i) => i.customerId === customerId)
);

go deeper

for a junior

Recall that the selector cache compares references, so a new array with the same contents counts as a change.

for a middle

Explain why an input selector that filters defeats the projector cache, and move the derivation into the projector.

for a senior

Diagnose from symptoms: count projector runs, audit inputs, look for factories called per read and shared parameterised instances. Know when content-aware result memoization is worth its comparison cost.

for a principal

Turn the findings into team rules: inputs are plain reads, factories are instantiated once per component, reducers return unchanged slices. Lint and review checklists catch these more cheaply than profiling.

## How the cache decides A selector built with `createSelector` holds **one** cache entry at two levels: the last root state it was called with, and the last results of its input selectors. Both are compared with strict equality (`===`). A hit needs the same **references** as last time; equal contents in a new array do not count. NgRx relies on immutable state to make this sound: an unchanged slice keeps its reference, and a changed one gets a new reference. So "it recomputes on every action" almost always means something is creating new references where the data did not change, or the single cache entry is being overwritten. ## The four usual causes 1. **A deriving input selector.** Inputs run whenever the root state changes, which is after almost any action. If an input filters, maps or builds an object, it returns a new reference each time, so the projector's argument never matches its cache. 2. **A new result from the projector.** When the projector does rerun, `invoices.filter(...)` or `{ invoices, total }` is a new reference even if its contents are identical. Every selector that uses it as an input then reruns too, and `store.select` emits because `distinctUntilChanged` compares with `===`. 3. **One instance, many parameters.** A parameterised selector in the deprecated props form, shared by two widgets showing different customers, overwrites its single entry on each read. Alternating reads always miss. 4. **A factory called on every read.** `selectCustomerView(id)` returns a new selector with an empty cache each time. Calling it inside a template method or getter means no read ever hits a cache. ## Fixing the view ```ts // Misses: the input selector builds a new array on every call export const selectCustomerViewSlow = createSelector( (state: AppState) => state.invoices.items.filter((i) => i.customerId === state.invoices.selectedCustomerId), (own) => ({ invoices: own, total: own.reduce((s, i) => s + i.amount, 0) }) ); // Hits: inputs return stored references; derivation lives in the projector export const selectCustomerView = createSelector( selectInvoices, selectSelectedCustomerId, (invoices, customerId) => { const own = invoices.filter((i) => i.customerId === customerId); return { invoices: own, total: own.reduce((s, i) => s + i.amount, 0) }; } ); ``` Now an action that changes another slice leaves `items` and `selectedCustomerId` identical, and the projector is skipped. | Cause | Symptom | Fix | |---|---|---| | Deriving input | projector runs after unrelated actions | inputs read references; derive in the projector | | New result reference | downstream selectors and views update with equal data | return primitives where possible; content-aware result memoization | | Shared parameterised instance | two widgets both miss | one factory instance per reader | | Factory per read | nothing is ever cached | create the instance once, in a field or `ngOnInit` | ## Keeping the old reference when contents match The fixed selector still returns a new object whenever any invoice changes, because `items` is then a new array. When that re-render matters, NgRx lets you swap the memoizer. `resultMemoize(projector, isResultEqual)` keeps the previous result when the comparator says the new one is equal, and `createSelectorFactory` builds a `createSelector` variant around it: ```ts const createListSelector = createSelectorFactory((projector) => resultMemoize(projector, sameInvoices) ); ``` For signal reads, `selectSignal`'s `equal` option achieves the same at the consumer, but it does not stop the projector from running. ## Diagnosing it - Log or count inside the projector for one session of clicks; a projector that runs on unrelated actions has a deriving input. - Check each input selector: anything beyond a property read or another memoized selector is suspect. - Search templates for selector factories called in methods, getters or bindings. - Confirm reducers return the same slice for actions they do not handle; a reducer that spreads state unconditionally creates new references for everyone. The opposite failure also exists: an **impure** projector, one that reads `Date.now()` for example, returns a stale cached value because nothing it depends on appears in its arguments. Memoization is correct only for pure functions of their inputs. ## When it is worth fixing A projector that sums fifty invoices costs microseconds, and rerunning it after every action is harmless. The fix matters when the projector is expensive, such as grouping thousands of invoices by customer and month, or when its new reference drives a large re-render. Measure first: the Redux DevTools extension shows which slices each action changed, and a projector that runs after an action touching none of its inputs points straight at a deriving input selector.

  • Why does the fixed selectCustomerView still hand the view a new object when another customer's invoice changes?
    Updating any invoice produces a new `items` array, so the projector's input changed and it reruns, building a new object even though this customer's invoices are the same. If that re-render matters, memoize the result by content with `createSelectorFactory` and `resultMemoize`, or give `selectSignal` an `equal` function; neither is needed for primitive results like a total.
  • Two widgets read one shared per-customer selector for different customers; what happens to its cache?
    The cache holds only the last arguments, so widget A's read overwrites widget B's entry and the next read of B misses, and so on. Each alternation reruns the projector. Give each widget its own instance from a factory selector, created once per component, so each cache sees stable arguments.

saying these in an interview costs you the question

  • createSelector caches every argument combination it has seen, like a lookup table.
  • Input selectors can filter freely, because createSelector memoizes whatever they return.
  • Calling a factory selector inside a template getter is fine, since each instance is memoized.
  • An equal function on selectSignal stops the selector's projector from rerunning.
  • A selector that returns equal contents in a new array counts as a cache hit downstream.