useReducer Patterns for Real State
Model non-trivial state as explicit transitions, and let the reducer be the single source of truth.
useReducer shines the moment your state has rules: fields that must change together, transitions that are only valid from certain states, or updates that depend on the previous value. Instead of scattering setX calls, you name each event and handle it in one pure function.
Think in events, not setters
Actions describe what happened ('submitted', 'field_changed', 'retry'), not how state changes. The reducer owns the how. This reads like a log of your app's behavior and is trivially testable — it is just reducer(state, action), no React required.
A tiny fetch machine
function reducer(state, action) {
switch (action.type) {
case 'start': return { status: 'loading', data: null, error: null };
case 'success': return { status: 'done', data: action.data, error: null };
case 'fail': return { status: 'error', data: null, error: action.error };
default: return state;
}
}Now impossible states are impossible: you can never be loading and hold an error at the same time, because no action produces that.
Handy patterns
- Lazy init — pass a third argument, an init function, to compute the initial state (e.g. from props) without recomputing every render.
- Dispatch is stable — React guarantees the
dispatchidentity never changes, so it is safe to pass down or drop into dependency arrays with nouseCallback. - Reducer + Context — put
stateanddispatchin context to give a whole subtree a mini store.
Example
import { useReducer } from 'react';
const initial = { items: [], draft: '', filter: 'all' };
function reducer(state, action) {
switch (action.type) {
case 'draft_changed':
return { ...state, draft: action.value };
case 'added': {
const text = state.draft.trim();
if (!text) return state; // guard invalid transition
return {
...state,
draft: '',
items: [...state.items, { id: crypto.randomUUID(), text, done: false }],
};
}
case 'toggled':
return {
...state,
items: state.items.map((i) =>
i.id === action.id ? { ...i, done: !i.done } : i
),
};
case 'filter_changed':
return { ...state, filter: action.value };
default:
return state;
}
}
function Todos() {
const [state, dispatch] = useReducer(reducer, initial);
const visible = state.items.filter((i) =>
state.filter === 'all' ? true : state.filter === 'done' ? i.done : !i.done
);
return (
<div>
<input
value={state.draft}
onChange={(e) => dispatch({ type: 'draft_changed', value: e.target.value })}
onKeyDown={(e) => e.key === 'Enter' && dispatch({ type: 'added' })}
/>
<ul>
{visible.map((i) => (
<li key={i.id} onClick={() => dispatch({ type: 'toggled', id: i.id })}>
{i.done ? 'x' : 'o'} {i.text}
</li>
))}
</ul>
</div>
);
}When to use it
- A checkout wizard uses a useReducer to model states like 'collecting_shipping', 'validating_payment', and 'confirmed' so illegal transitions are impossible by construction.
- A real-time collaboration editor dispatches INSERT_TEXT, DELETE_RANGE, and SET_CURSOR actions to a reducer, making the entire operation log inspectable and replayable.
- A form with cross-field validation uses a reducer to reset dependent fields automatically when a parent field changes — logic that would require multiple useState calls to coordinate.
More examples
State machine with useReducer
Models fetch states as a finite state machine in a reducer so only valid transitions are possible.
import { useReducer } from 'react';
const TRANSITIONS = {
idle: { SUBMIT: 'loading' },
loading: { SUCCESS: 'success', FAIL: 'error' },
success: { RESET: 'idle' },
error: { RESET: 'idle' },
};
function reducer(state, action) {
const next = TRANSITIONS[state.status]?.[action.type];
if (!next) return state;
return { status: next, data: action.data ?? state.data, error: action.error ?? null };
}
function FetchButton({ onFetch }) {
const [state, dispatch] = useReducer(reducer, { status: 'idle', data: null, error: null });
return (
<button
onClick={async () => {
dispatch({ type: 'SUBMIT' });
try { dispatch({ type: 'SUCCESS', data: await onFetch() }); }
catch (e) { dispatch({ type: 'FAIL', error: e.message }); }
}}
disabled={state.status === 'loading'}
>
{state.status === 'loading' ? 'Loading...' : 'Fetch'}
</button>
);
}Reducer with action creators
Centralises cart transitions in a reducer and provides action creators so callers never hardcode action objects.
// Action creators give transitions meaningful names and type safety
export const actions = {
addItem: item => ({ type: 'ADD_ITEM', item }),
removeItem: id => ({ type: 'REMOVE_ITEM', id }),
setQuantity: (id, qty) => ({ type: 'SET_QTY', id, qty }),
};
export function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
return [...state, { ...action.item, qty: 1 }];
case 'REMOVE_ITEM':
return state.filter(i => i.id !== action.id);
case 'SET_QTY':
return state.map(i => i.id === action.id ? { ...i, qty: action.qty } : i);
default:
return state;
}
}Immer-style immutable update
Uses Immer's produce as the reducer so deep state mutations are written as direct assignments, not spreads.
import { useReducer } from 'react';
import { produce } from 'immer';
const reducer = produce((draft, action) => {
switch (action.type) {
case 'TOGGLE_DONE':
const task = draft.find(t => t.id === action.id);
if (task) task.done = !task.done;
break;
case 'ADD':
draft.push({ id: Date.now(), text: action.text, done: false });
break;
}
});
function TodoList() {
const [tasks, dispatch] = useReducer(reducer, []);
return <ul>{tasks.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
}
Discussion