React's Context API & useReducer — Advanced State ManagementEntry 5 of 7
useReducer: Actions, Payloads, and Pure Reducers
A hook that manages state through a reducer function: components dispatch actions, and the reducer computes the next state.
Notes
const [state, dispatch] = useReducer(reducer, initialState);
- Reducer: a pure function
(state, action) => newState. No side effects, no mutation; it always returns a new state. - Action: an object describing what happened, with a
typeand an optionalpayloadcarrying the data. - Dispatch: the function components call to send an action. The UI never changes state directly; it describes the event, and the reducer decides the result.
In TypeScript, model actions as a discriminated union. Each case then knows its exact payload type, and the compiler flags unknown action types.
What / Why / When
- What
Centralized, event-driven state updates.
- Why
All state transitions live in one testable function, separate from the UI.
- When
State has several related values or many ways to change, or the next state depends on the previous one in non-trivial ways. For a simple toggle or single value,
useStateis enough.- Use case
A player manager with add, update, remove, and reset actions.
Code compare
// players-reducer.tsexport type Player = { id: string; name: string };​type PlayersState = { players: Player[] };​type PlayersAction = | { type: "add-player"; payload: Player } | { type: "update-player"; payload: Player } | { type: "remove-player"; payload: { id: string } } | { type: "reset" };​export const initialState: PlayersState = { players: [] };​export function playersReducer(state: PlayersState, action: PlayersAction): PlayersState { switch (action.type) { case "add-player": return { ...state, players: [...state.players, action.payload] };​ case "update-player": return { ...state, players: state.players.map((p) => p.id === action.payload.id ? { ...p, name: action.payload.name } : p, ), };​ case "remove-player": return { ...state, players: state.players.filter((p) => p.id !== action.payload.id) };​ case "reset": return initialState; }}Angular has no built-in reducer. The idiomatic equivalent is a service whose methods play the role of actions:
// player.store.tsimport { Injectable, signal } from "@angular/core";​export type Player = { id: string; name: string };​@Injectable({ providedIn: "root" })export class PlayerStore { private readonly _players = signal<Player[]>([]); readonly players = this._players.asReadonly();​ addPlayer(name: string) { this._players.update((list) => [...list, { id: crypto.randomUUID(), name }]); }​ updatePlayer(id: string, name: string) { this._players.update((list) => list.map((p) => (p.id === id ? { ...p, name } : p))); }​ removePlayer(id: string) { this._players.update((list) => list.filter((p) => p.id !== id)); }​ reset() { this._players.set([]); }}Differences and gotchas
dispatch({ type: 'remove-player', payload: { id } })in React becomes a method callstore.removePlayer(id)in Angular. Same idea, less ceremony.- React's reducer is a standalone pure function, easy to unit-test without rendering. In Angular, test the service's methods instead.
- For a reducer-style or Redux-style store in Angular, NgRx provides
signalState/SignalStoreand the classic NgRx Store. - With a discriminated union, TypeScript checks the
switchfor you. In plain JavaScript, adddefault: throw new Error('Unknown action').
Quick reference
| React | Angular |
|---|---|
useReducer(reducer, initialState) | Service with a private signal() |
dispatch({ type, payload }) | store.method(args) |
Reducer case | Service method using update() |
| Pure reducer (unit-testable) | Service methods (unit-testable) |