Introduction
NgRx selectors derive state from the store and emit new values when the source state changes. When a reducer mutates state instead of returning a new object, selectors do not detect the change and continue returning stale values:
// BAD: mutates state - selector will NOT emit
const reducer = createReducer(
initialState,
on(updateUser, (state, { user }) => {
state.name = user.name; // Mutation! Selector won't detect this
return state;
}),
);The result is that the UI does not update even though the reducer ran successfully.
Symptoms
- UI does not reflect state changes after dispatching actions
- NgRx DevTools shows the state was updated but selectors return old values
asyncpipe in template does not trigger re-render- Selectors emit once on initialization but never again
- State appears correct in Redux DevTools but components show stale data
Common Causes
- Reducer mutates state object instead of returning a new object
- Using
Object.assignon the existing state reference without proper nesting - Array mutations (push, splice) instead of creating new arrays
- Nested object mutation where only the top-level reference is the same
- Using Immer incorrectly, not returning the draft state
Step-by-Step Fix
- 1.Always return new state objects from reducers (never mutate):
- 2.```typescript
- 3.// BAD: mutation
- 4.on(updateUser, (state, { user }) => {
- 5.state.name = user.name;
- 6.return state;
- 7.});
// GOOD: immutable update with spread on(updateUser, (state, { user }) => ({ ...state, user: { ...state.user, name: user.name, }, })); ```
- 1.Use the createReducer with immutable patterns for nested state:
- 2.```typescript
- 3.interface AppState {
- 4.users: User[];
- 5.selectedUserId: string | null;
- 6.}
const reducer = createReducer( initialState, on(addUser, (state, { user }) => ({ ...state, users: [...state.users, user], // New array })), on(updateUser, (state, { user }) => ({ ...state, users: state.users.map(u => u.id === user.id ? { ...u, ...user } : u // New object per item ), })), on(removeUser, (state, { userId }) => ({ ...state, users: state.users.filter(u => u.id !== userId), // New array })), ); ```
- 1.Use Immer for simpler immutable updates:
- 2.```typescript
- 3.import { createReducer, on } from '@ngrx/store';
- 4.import { produce } from 'immer';
export const reducer = createReducer( initialState, on(updateUser, (state, { user }) => produce(state, (draft) => { const index = draft.users.findIndex(u => u.id === user.id); if (index !== -1) { draft.users[index].name = user.name; // Looks like mutation, but Immer handles it } }) ), ); ```
- 1.Debug selector emissions to verify they fire on state changes:
- 2.```typescript
- 3.this.store.select(selectUser).pipe(
- 4.tap(value => console.log('Selector emitted:', value)),
- 5.).subscribe();
- 6.
` - 7.Verify selector memoization is working correctly:
- 8.```typescript
- 9.export const selectUser = createSelector(
- 10.selectAppState,
- 11.(state) => {
- 12.console.log('Selector projector called'); // Should log on each state change
- 13.return state.users.find(u => u.id === state.selectedUserId);
- 14.}
- 15.);
- 16.
`
Prevention
- Enable runtime checks in NgRx to catch state mutations:
- ```typescript
- StoreModule.forRoot(reducers, {
- runtimeChecks: {
- strictStateImmutability: true,
- strictActionImmutability: true,
- strictStateSerializability: true,
- strictActionSerializability: true,
- },
- })
`- Always use spread operator or Immer for state updates
- Never use
push,splice,pop, or direct property assignment on state - Write reducer tests that verify the returned state is a new object reference
- Use NgRx DevTools to inspect state changes after each dispatched action
- Add an ESLint rule to flag common mutation patterns in reducer files
- Use TypeScript's
readonlymodifier on state interfaces to catch mutations at compile time