Mastering NgRx createReducer: Immutable State Transitions, Practical Example, and Common Pitfalls
Learn how NgRx’s createReducer enforces immutable state changes with a clear example, avoid common pitfalls, and understand when to use effects or EntityAdapter for more complex scenarios.
05 Feb 2026, 05:47 UTC

Why Use createReducer?
NgRx’s createReducer is the core API for defining how a store slice changes in response to actions. It replaces the old switch‑case style with a declarative map of actions to pure reducer functions. The benefits are:
- Explicit initial state and type safety.
- Immutable updates that trigger Angular’s change detection.
- Clear separation of state logic from side‑effects.
A Worked Counter Example
Below is a minimal counter reducer that demonstrates the pattern. The code runs in an Angular project that has @ngrx/store@v17 installed.
import { createReducer, on } from '@ngrx/store';
import { increment, decrement, reset } from './counter.actions';
export interface CounterState {
count: number;
}
export const initialState: CounterState = {
count: 0,
};
export const counterReducer = createReducer(
initialState,
on(increment, state => ({ ...state, count: state.count + 1 })),
on(decrement, state => ({ ...state, count: state.count - 1 })),
on(reset, () => ({ ...initialState }))
);
Key points:
- The reducer never mutates
state; it returns a new object. - Each
onhandler is a pure function – no side‑effects, no asynchronous work. - The default case is implicit: if an action isn’t matched, the current state is returned unchanged.
Handling Asynchronous Logic
Reducers must remain synchronous. If an action originates from an async source (e.g., an HTTP call), dispatch a plain action that the reducer can handle. The async work itself should live in an Effect:
import { createEffect, ofType, Actions } from '@ngrx/effects';
import { map } from 'rxjs/operators';
import { loadData, loadDataSuccess } from './data.actions';
export const loadDataEffect = createEffect(() =>
actions$.pipe(
ofType(loadData),
// side‑effect: call a service
map(() => loadDataSuccess({ payload: { /* data */ } }))
)
);
The effect dispatches loadDataSuccess, which a reducer can then process synchronously.
Common Mistakes to Avoid
- Mutating state directly – e.g.,
state.count++. This breaks immutability, prevents change detection, and makes debugging hard. - Omitting the initial state –
createReducerrequires it; otherwise the store can start inundefined. - Missing an
onhandler for a dispatched action – the store throws a runtime error. Either add a handler or let the reducer return the current state. - Using a reducer for complex flows – for many entities or deep updates, consider
EntityAdapteror move logic to an effect.
Limitations and When to Use Alternatives
| Aspect | Limit |
|---|---|
| Side‑effects | Not allowed – use effects. |
| Async state changes | Must be driven by actions; reducer stays sync. |
| Nested state updates | Requires manual spread or helper libraries; can get verbose. |
| Large entity collections | EntityAdapter offers optimized CRUD helpers. |
Testing and Verification
Verify that your reducer behaves as expected with unit tests:
import { counterReducer, initialState } from './counter.reducer';
import { increment } from './counter.actions';
describe('counterReducer', () => {
it('should increment count', () => {
const result = counterReducer(initialState, increment());
expect(result.count).toBe(1);
});
});
When running the app, dispatch actions via the store and subscribe to the slice:
store.dispatch(increment());
store.select(state => state.counter).subscribe(c => console.log(c.count));
If the console shows the updated value, the reducer is correctly updating state immutably.
Practical Checklist Before Deploying
- All actions referenced in the UI have corresponding
onhandlers or a default case. - State objects are never mutated in place.
- Async work is isolated to effects.
- Unit tests cover each action path.
- Store modules import the reducer with
forRootorforFeaturecorrectly.
Adhering to these practices ensures predictable state transitions, easier debugging, and a smoother Angular+NgRx experience.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.