In NgRx 22, why must an on() handler inside createReducer return a new state object instead of mutating the slice it receives?
answer
- same input, same output
- change detected by reference
- combineReducers keeps the old root
- frozen state in development
- unmatched action returns the same slice
basics
~20 sAn 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 linesimport { 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
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.
Explain the reference checks at each layer, from combineReducers through select to memoized selectors, and why an unmatched action returns the very same slice.
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.
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.