skip to content

In NgRx 22, how do createFeatureSelector and createSelector compose an invoice dashboard's overdue count, and what does each memoized selector cache?

level: middleimportance: must knowfreq 50%

answer

  1. top-level key, then projections
  2. inputs run, projector receives results
  3. two caches: root state, input results
  4. strict equality, cache size one
  5. release() resets to null

basics

~10 s

createFeatureSelector reads the invoices key; createSelector chains inputs into a projector. Each memoized selector caches only its last arguments and result, compared with ===, and keeps them until the next miss or release().

solid answer

~40 s

`createFeatureSelector<InvoicesState>('invoices')` returns a memoized selector for the `invoices` key registered by `provideState`. `createSelector(selectInvoices, (invoices) => invoices.filter(isOverdue).length)` runs each input selector against the state and hands the results to the projector. Each selector keeps one cache entry: the last root state it was called with and its result, plus the projector's last input results. A call with the same root state returns the cached value; a new root state reruns the inputs, and the projector runs again only if an input result differs by `===`. So an action that changes only `selectedCustomerId` leaves the `items` array identical and the overdue count comes from cache. The cache lives as long as the selector, usually the app; `release()` resets it to `null` and releases the memoized selectors it uses as inputs.

code

ts · 15 lines
ts
import { createSelector } from '@ngrx/store';
import { selectInvoices, selectSelectedCustomerId } from './invoices.selectors';

// Dictionary form: createSelector generates the projector
export const selectDashboardInputs = createSelector({
  invoices: selectInvoices,
  customerId: selectSelectedCustomerId,
});

export const selectCustomerOverdue = createSelector(
  selectInvoices,
  selectSelectedCustomerId,
  (invoices, customerId) =>
    invoices.filter((i) => i.customerId === customerId && i.status === 'overdue')
);

go deeper

for a junior

Recall the shape: createFeatureSelector for the slice key, createSelector with input selectors and a projector last. Know that the result is cached.

for a middle

Explain the two caches, root-state arguments and projector inputs, both single-entry and compared with ===. Trace one action through a chain and say which projectors run.

for a senior

Point out what the cache assumes, immutable state and pure projectors, and what breaks it: filtering inputs, Date.now() in a projector, a large result held forever. Say when release() is worth calling and what it does not free.

for a principal

Relate the design to cost: reference checks make reads nearly free across hundreds of selectors, but only if the team treats state as immutable and keeps inputs as plain reads. That is a code-review rule worth writing down.

## The two building blocks **`createFeatureSelector`** is the entry point for a feature slice. `createFeatureSelector<InvoicesState>('invoices')` returns a function that reads `state['invoices']`, the key under which the feature was registered with `provideState('invoices', invoicesReducer)` (or `provideState({ name, reducer })`). If the key is not in the state yet, for example because the lazy route that provides it has not loaded, it returns `undefined` and, in dev mode, logs a console warning naming the missing feature. It is itself built on `createSelector`, so it is memoized too. The older two-generic form that reads from a typed root state is deprecated; the one-generic form above is the current spelling. **`createSelector`** takes one or more **input selectors** and a **projector** as the last argument. It can also take a dictionary, `createSelector({ invoices: selectInvoices, customerId: selectSelectedCustomerId })`, and generate a projector that returns an object with those keys. ```ts export const selectInvoicesState = createFeatureSelector<InvoicesState>('invoices'); export const selectInvoices = createSelector(selectInvoicesState, (s) => s.items); export const selectSelectedCustomerId = createSelector(selectInvoicesState, (s) => s.selectedCustomerId); export const selectOverdueCount = createSelector(selectInvoices, (invoices) => invoices.filter((i) => i.status === 'overdue').length ); ``` ## What each selector caches Every selector returned by `createSelector` holds **two single-entry caches**: 1. **The outer cache**, keyed on the arguments the selector itself was called with: the root state (and, for the deprecated props form, the props object). Same reference as last time means the cached result is returned without running anything. 2. **The projector cache**, keyed on the results of the input selectors. When the root state is new, every input selector runs; the projector runs again only if at least one input result differs from last time. Both compare with strict equality (`===`) by default, and both remember only the **last** call. There is no history of earlier arguments. When the projector does rerun and returns a value `===` to its previous result, the previous reference is kept. ## Tracing one action Suppose a reducer handles `customerSelected` by returning `{ ...state, selectedCustomerId: id }`. | Selector | Called with | What happens | |---|---|---| | `selectInvoicesState` | new root state | reruns, returns the new slice object | | `selectInvoices` | new root state | projector reruns (its input changed) but returns the **same** `items` array | | `selectOverdueCount` | new root state | inputs run; `items` is `===` last time, so the projector is **skipped** and the cached count returned | | `selectSelectedCustomerId` | new root state | returns the new id, so anything built on it recomputes | If an action changes no slice at all, reducers return their state unchanged and the root state keeps its reference, so every selector answers from its outer cache. ## How long the cache lives, and `release()` Selectors are usually module-level constants, so their caches live for the life of the application. The docs note that a memoized value "stays in memory indefinitely". That is harmless for a count, less so for a large derived list. `selectOverdueCount.release()` sets its cached arguments and result back to `null`, and it also calls `release()` on every **memoized** selector passed to it as an input, here `selectInvoices` and through it `selectInvoicesState`. A plain arrow-function input has no cache and is skipped. Three cautions: - `release()` frees the **selector's** copy of a derived result. The invoices in the store are untouched; they leave memory only if a reducer removes them. - Releasing a shared input resets it for every other selector that uses it. The next read simply recomputes, so this costs time, not correctness. - A value forced onto a selector with `setResult` is not cleared by `release()`; `clearResult` removes it. ## Why this design A single-entry, reference-based cache is cheap: checking it costs a few `===` comparisons, never a deep walk of the state. It is correct because NgRx state is immutable, so an unchanged reference really does mean unchanged data. The price is that anything producing new references for the same data, such as an input selector that filters, defeats it; that failure mode is the reason to keep inputs as simple property reads and put derivation in projectors. ## Mistakes that break the chain - Putting the derivation in an input selector, `(state) => state.invoices.items.filter(...)`, which returns a new array on every call and makes the projector's cache miss. - Mutating the invoices array in a reducer: the reference stays the same, so every selector keeps returning the stale cached count. In dev mode the default `strictStateImmutability` check freezes state and the mutation throws first; in production nothing catches it. - Mismatching the key: `createFeatureSelector('invoice')` against `provideState('invoices', ...)` silently reads `undefined`, with only the dev-mode warning as a clue.

  • What goes wrong if the overdue-count projector compares due dates with Date.now()?
    The projector is assumed pure, and its cache is keyed only on its input results. The count is computed on the first call and returned unchanged until the invoices array changes, even as days pass and more invoices fall due. Make the reference date an input: keep it in state, updated by an action, or pass it to a factory selector, so a new date is a new argument.
  • When is calling release() on a selector worth it?
    When a module-level selector caches a large derived result that the app no longer needs, such as an export-sized invoice list after leaving its route. release() drops the cached arguments and result and those of its memoized inputs. It does not shrink the store: the underlying invoices stay until a reducer removes them, and the next read simply recomputes.

A memoized selector is like a spreadsheet formula cell that recalculates only when a cell it references is replaced. Unlike a spreadsheet, it notices replacement, not edits made in place, which is why NgRx state must be updated immutably.

saying these in an interview costs you the question

  • createFeatureSelector creates an empty invoices slice when the key is missing.
  • createSelector compares input arrays by content, so an equal copy is a cache hit.
  • A memoized selector keeps a cache entry for every state it has ever seen.
  • release() deletes the invoices from the store to free memory.
  • Every selector in a chain reruns its projector after any dispatched action.