skip to content

Reducers & Feature Slices

Reducers turn an action and the current slice into the next slice, and createFeature bundles one with its selectors. Interviewers probe immutability and what the runtime checks catch.

on this pageshow

explore

questions

5

In NgRx 22, why must an on() handler inside createReducer return a new state object instead of mutating the slice it receives?

level: juniorimportance: must knowfreq 62%

answer

  1. same input, same output
  2. change detected by reference
  3. combineReducers keeps the old root
  4. frozen state in development
  5. unmatched action returns the same slice

basics

~20 s

An NgRx reducer is a pure function whose new object is how the store detects a change: selectors and select() compare by reference. In development the default immutability check freezes state, so a mutation throws instead of failing silently.

solid answer

~40 s

`createReducer(initialState, on(action, handler))` builds a pure function: the same slice and action always give the same result, with no side effects. NgRx notices a change by **reference**: `combineReducers` keeps the old root object when every slice returns the reference it received, `store.select` passes values through `distinctUntilChanged`, and memoized selectors skip work when their inputs are identical. So `state.orders.push(order); return state;` produces no new reference, and the admin view never updates. In development the default `strictStateImmutability` check deep-freezes every state a reducer returns, so that `push` throws a `TypeError` on the spot; production builds turn every runtime check off, and the same bug ships silently as a stale view. Returning the same slice is still correct when nothing changed, which is exactly what `createReducer` does for an action no `on()` lists.

code

ts · 22 lines
ts
import { createReducer, on } from '@ngrx/store';
import { OrdersPageActions, OrdersApiActions } from './orders.actions';

export const ordersReducer = createReducer(
  initialOrdersState,
  on(OrdersPageActions.statusChanged, (state, { id, status }) => {
    const current = state.orders.find((o) => o.id === id);
    if (!current || current.status === status) {
      return state; // nothing changes: keep the reference
    }
    return {
      ...state,
      orders: state.orders.map((o) => (o.id === id ? { ...o, status } : o)),
    };
  }),
  on(OrdersApiActions.bulkArchiveSucceeded, (state, { ids }) => ({
    ...state,
    orders: state.orders.map((o) =>
      ids.includes(o.id) ? { ...o, archived: true } : o
    ),
  }))
);

go deeper

for a junior

Recall that a reducer takes the slice and an action and returns a new slice without touching either input, and that the new reference is how NgRx notices the change.

for a middle

Explain the reference checks at each layer, from combineReducers through select to memoized selectors, and why an unmatched action returns the very same slice.

for a senior

Show that you know the freeze only runs in development, so a mutation that slips past review ships as a stale view, and keep impure inputs such as timestamps in action payloads.

for a principal

Frame purity as what makes replay, time-travel debugging and deterministic reducer tests possible, and weigh the development-only freeze against its cost on very large state trees.

## What a reducer is in NgRx In NgRx, the **global Store** holds one state tree, split into **slices** (also called features) such as an `orders` slice for an admin area. A **reducer** is the function that owns one slice: NgRx calls it with the slice's current value and the action that was just dispatched, and the reducer returns the slice's next value. You rarely write that function by hand. `createReducer(initialState, ...ons)` builds it from **`on()` handlers**, each of which lists one or more action creators and a function `(state, action) => nextState`. The generated reducer looks the action's `type` up in a map, calls the matching handler, and otherwise returns the state it was given. A reducer must be **pure**: - the same slice and the same action always produce the same result; - it performs no side effects: no HTTP calls, no writes to storage, no dispatching, no reading of the clock or random numbers; - it never modifies its inputs, neither the state nor the action. ## How NgRx notices that something changed NgRx never deep-compares two states. Every layer asks one cheap question: is this the same object reference as last time? | Layer | What it compares | What a mutated-and-returned slice causes | |---|---|---| | `combineReducers` (builds the root) | each slice's old and new reference | keeps the old root object, so the store sees no change | | `store.select(...)` | successive results via `distinctUntilChanged` | emits nothing new to the template | | memoized selectors | their input references | return the cached result, built from the old array | | `OnPush` components reading an input | the input reference | skip checking the view | This is why "return a new object" is not a style rule. The new reference **is** the change notification. If `on()` pushes into `state.orders` and returns `state`, every layer above sees the same reference and concludes that nothing happened, even though the array's contents changed underneath it. ## What happens if you mutate anyway NgRx ships **runtime checks**, a set of meta-reducers that wrap your reducers in development. Two are on by default: 1. `strictStateImmutability` deep-freezes (`Object.freeze`) the state each reducer returns. The next handler that tries `state.orders.push(...)` or `state.loading = false` hits a frozen object and throws a `TypeError`, pointing at the exact line. 2. `strictActionImmutability` freezes each action before your reducers see it, so writing into an action's payload throws too. Both checks are **development-only**. In a production build NgRx forces every runtime check to `false`, whatever you configured, because deep-freezing a large state on every action costs time. The mutation that would have thrown in development instead ships as a view that silently stops updating. Treat a thrown `TypeError` from a frozen object as a gift. ## Writing the orders handlers correctly For the admin `orders` slice, each handler builds a new slice and copies only the path that changes: - a **status change** maps over `orders`, replacing the one matching order with a spread copy and keeping every other element's reference; - a **bulk archive** maps the same array, flagging the listed ids; - a handler whose action changes nothing (the order already has that status) may return `state` itself. Keeping untouched elements at their old references matters: a selector or a row component that depends only on order 17 sees no change when order 42 is archived. Anything impure belongs elsewhere. Saving the new status to the server, or reading `Date.now()` for an `archivedAt` field, happens outside the reducer, and the result arrives as data in the next action. That way, replaying the same actions in the Redux DevTools reproduces the same states. ## Returning the same slice is sometimes the right answer Purity does not mean "always allocate". Two cases legitimately return the reference they received: - **an unmatched action**: every dispatched action reaches every registered reducer, and the reducer `createReducer` builds returns its current state when no `on()` lists that action type; - **a no-op update**: a handler may detect that nothing would change and return `state`. In both cases, the unchanged reference lets `combineReducers` keep the old root object and lets selectors skip their projectors. Allocating a copy for every action would make every `select` emit on every dispatch. ## Key points - The **new reference is the signal**; NgRx compares by identity at every layer. - The two **immutability checks** are on by default, but only in development. - **Side effects and non-deterministic values** stay out of `on()` handlers and arrive through actions. - Return the **same slice** when nothing changed.

  • Why does the mutation throw in development but go unnoticed in a production build?
    NgRx builds its runtime checks from `isDevMode()`. In development `strictStateImmutability` defaults to `true` and deep-freezes each returned state, so a later `push` throws. In production every runtime check is forced to `false`, even ones you enabled, because freezing a large tree on every action is expensive. The mutation then just returns an unchanged reference, and the view stays stale.
  • Is an on() handler wrong if it returns the state it received?
    No. When the action changes nothing, returning the same reference is correct and cheap: `combineReducers` keeps the old root, and selectors skip their projectors. `createReducer` itself does this for any action no `on()` lists. The rule is narrower than "always copy": never change an existing object; allocate only along the path that actually changes.
  • Where should an archivedAt timestamp come from, if not Date.now() inside the reducer?
    From the action. Whatever dispatches the archive success puts the timestamp in the payload, ideally as an ISO string, and the reducer copies it into state. The reducer stays deterministic, so replaying the same actions in the DevTools gives the same states, and a test can assert on a fixed value.

saying these in an interview costs you the question

  • Mutating the state is fine as long as the handler returns it afterwards.
  • NgRx deep-compares the old and new state to decide whether anything changed.
  • The immutability runtime check also protects production builds.
  • A reducer may call an HTTP service as long as it still returns the new state.
  • Every on() handler must allocate a new object, even when nothing changed.
open as a page

In NgRx 22, how do you define an orders slice with createFeature and register it only when a lazy admin route loads?

level: middleimportance: must knowfreq 50%

basics

~20 s

createFeature({ name: 'orders', reducer }) bundles the reducer with a feature selector and one selector per state property. Putting provideState(ordersFeature) in the admin route's providers adds the slice to the single store when that route first activates.

open as a page

In NgRx 22, how would you write a meta-reducer that resets every slice, including a lazily registered orders feature, when the admin logs out?

level: seniorimportance: should knowfreq 30%

basics

~20 s

A meta-reducer wraps the root reducer: on the logout action it calls the inner reducer with undefined state, so every slice falls back to its initial state. Registered in provideStore's metaReducers, it also covers features added later by lazy routes.

open as a page

In NgRx 22, which of the store's six runtime checks are on by default, what does each one catch, and when would you enable the others?

level: seniorimportance: should knowfreq 26%

basics

~10 s

NgRx 22 enables strictStateImmutability and strictActionImmutability by default in development; the two serializability checks, strictActionWithinNgZone and strictActionTypeUniqueness start off. Production builds switch all six off, whatever the config says.

open as a page

In NgRx 22, what does createFeature's extraSelectors factory receive, and when should a derived orders selector live there rather than in a separate selectors file?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

The extraSelectors factory receives the selectors createFeature generated (the feature selector and one per property) and returns more, which are merged into the feature object. It suits selectors derived from that one slice; joins across features belong in a separate file.

open as a page