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:

typescript
// 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
  • async pipe 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.assign on 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. 1.Always return new state objects from reducers (never mutate):
  2. 2.```typescript
  3. 3.// BAD: mutation
  4. 4.on(updateUser, (state, { user }) => {
  5. 5.state.name = user.name;
  6. 6.return state;
  7. 7.});

// GOOD: immutable update with spread on(updateUser, (state, { user }) => ({ ...state, user: { ...state.user, name: user.name, }, })); ```

  1. 1.Use the createReducer with immutable patterns for nested state:
  2. 2.```typescript
  3. 3.interface AppState {
  4. 4.users: User[];
  5. 5.selectedUserId: string | null;
  6. 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. 1.Use Immer for simpler immutable updates:
  2. 2.```typescript
  3. 3.import { createReducer, on } from '@ngrx/store';
  4. 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. 1.Debug selector emissions to verify they fire on state changes:
  2. 2.```typescript
  3. 3.this.store.select(selectUser).pipe(
  4. 4.tap(value => console.log('Selector emitted:', value)),
  5. 5.).subscribe();
  6. 6.`
  7. 7.Verify selector memoization is working correctly:
  8. 8.```typescript
  9. 9.export const selectUser = createSelector(
  10. 10.selectAppState,
  11. 11.(state) => {
  12. 12.console.log('Selector projector called'); // Should log on each state change
  13. 13.return state.users.find(u => u.id === state.selectedUserId);
  14. 14.}
  15. 15.);
  16. 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 readonly modifier on state interfaces to catch mutations at compile time