A reducer in NgRx must be a pure function: same input state + action must always produce the same next state, with no side effects. Interviewers test reducer immutability and whether you can spot forbidden reducer side effects like mutation, Math.random(), HTTP, router navigation, and service calls.
NgRx reducer pure function in practice: immutability, side effects, and common mistakes
Use guided tracks for structured prep, then practice company-specific question sets when you want targeted interview coverage.
Core idea
An NgRx reducer is a synchronous, deterministic state transition function. It should only compute the next immutable state from (state, action). If it reaches outside (network, router, random values, services), it is no longer a pure reducer.
Forbidden in reducers | Why it is wrong | Where it belongs |
|---|---|---|
State mutation ( | Breaks immutability and memoization assumptions | Return new objects/arrays in reducer |
| Same input may produce different output | Generate IDs/timestamps before dispatch or in effects |
HTTP calls | Async side effect inside supposedly pure function | Effects |
Router navigation | External side effect not a state calculation | Effects or component/facade orchestration |
Service calls (analytics, storage, logging APIs) | Leaks imperative side effects into reducer | Effects or dedicated services triggered elsewhere |
// ❌ Bad reducer: impure + mutating
export const ordersReducer = createReducer(
initialState,
on(placeOrder, (state, { item, router, api, audit }) => {
state.orders.push(item); // mutation
const id = Math.random().toString(36).slice(2); // non-deterministic
api.postOrder(item).subscribe(); // HTTP side effect
router.navigate(['/orders', id]); // navigation side effect
audit.track('order_placed', item); // service side effect
return state; // same reference returned
})
);
// ✅ Good reducer: pure + immutable
export interface OrdersState {
orders: Array<{ id: string; name: string }>;
loading: boolean;
error: string | null;
}
export const initialState: OrdersState = {
orders: [],
loading: false,
error: null
};
export const ordersReducer = createReducer(
initialState,
on(placeOrder, state => ({ ...state, loading: true, error: null })),
on(placeOrderSuccess, (state, { order }) => ({
...state,
orders: [...state.orders, order],
loading: false
})),
on(placeOrderFailure, (state, { error }) => ({ ...state, error, loading: false }))
);
// ✅ Side effects move to effects
@Injectable()
export class OrdersEffects {
placeOrder$ = createEffect(() =>
this.actions$.pipe(
ofType(placeOrder),
switchMap(({ draft }) =>
this.api.postOrder(draft).pipe(
map(order => placeOrderSuccess({ order })),
catchError(err => of(placeOrderFailure({ error: String(err) })))
)
)
)
);
navigateOnSuccess$ = createEffect(
() =>
this.actions$.pipe(
ofType(placeOrderSuccess),
tap(({ order }) => this.router.navigate(['/orders', order.id]))
),
{ dispatch: false }
);
constructor(
private actions$: Actions,
private api: OrdersApiService,
private router: Router
) {}
}
Reducer purity checklist | Pass condition |
|---|---|
Deterministic output | Same state + action always yields same next state |
No mutation | Original state untouched; new references returned |
No async work | No HTTP/subscription/promise in reducer |
No framework side effects | No router navigation, no service invocations |
Synchronous and small | Just state transition logic, nothing else |
Interview summary
An NgRx reducer pure function does one thing: compute next immutable state. It must avoid mutation and all side effects, including Math.random(), HTTP, router calls, and service usage. Put side effects in effects, keep reducers deterministic, and selector memoization and debugging stay reliable.