useReducer in React and Why It Beats useState Sometimes
Understand React's useReducer hook, its signature, action shape, when to migrate from useState, and how to build a small state machine pattern for predictable UI logic.
What you'll learn
- ✓The signature and mental model of useReducer
- ✓How to design action objects that scale
- ✓Concrete signals that useState has outgrown itself
- ✓Building a tiny state machine with useReducer
- ✓How reducers improve testability and debugging
Prerequisites
- •Comfort with useState and useEffect
- •Basics of props and state
useState is the first hook every React developer learns, and for good reason. It is simple, direct, and works for most local state. But as a component grows, multiple useState calls start to interact in awkward ways. useReducer is React’s answer to that, and it shines when state transitions get complicated.
The Signature
useReducer takes a reducer function and an initial state and returns the current state along with a dispatch function.
const [state, dispatch] = useReducer(reducer, initialState);
The reducer has the shape (state, action) => newState. It must be pure: no side effects, no mutating the input. Given the same state and action, it must return the same result. This is the same contract that Redux uses, and the discipline is the whole point.
type State = { count: number };
type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
}
}
A counter is the canonical toy example, but it does not show why you would reach for useReducer. Let’s look at a more realistic one.
Action Shape Matters
Action objects are how you describe transitions. A common shape is { type: string, payload?: unknown }, but the only real requirement is that it be serializable and discriminated. With TypeScript, a union of action types gives you exhaustive checking in the reducer.
type Action =
| { type: 'add'; text: string }
| { type: 'remove'; id: string }
| { type: 'toggle'; id: string }
| { type: 'clearCompleted' };
Keep actions describing intent rather than mechanics. { type: 'toggle', id } is better than { type: 'setDone', id, value: true } because the reducer encodes the logic of toggling, and the caller does not need to know the current value.
When to Migrate from useState
There is no hard rule, but there are reliable signals. Consider migrating when:
- You have three or more pieces of related state that always change together.
- A single user action updates several state values in a coordinated way.
- You have setters that contain logic, like
setItems((prev) => prev.map(...))in multiple places with similar shapes. - Setting one state requires reading another, which causes stale-closure bugs.
- You want to pass a single dispatch function down through context instead of a handful of setters.
A form is a classic candidate. Here is the useState version of a small login form.
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// every submit handler has to touch most of these
}
The useReducer version centralizes the transitions.
type FormState = {
email: string;
password: string;
status: 'idle' | 'submitting' | 'error';
error: string | null;
};
type FormAction =
| { type: 'field'; name: 'email' | 'password'; value: string }
| { type: 'submit' }
| { type: 'success' }
| { type: 'failure'; error: string };
function reducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'field':
return { ...state, [action.name]: action.value };
case 'submit':
return { ...state, status: 'submitting', error: null };
case 'success':
return { ...state, status: 'idle' };
case 'failure':
return { ...state, status: 'error', error: action.error };
}
}
Notice that status collapses what was previously two booleans (submitting, error-or-not) into a single field with named values. That is the seed of a state machine.
A Tiny State Machine
A finite state machine has a fixed set of states and a fixed set of transitions between them. useReducer is a natural fit because the reducer is already defined as (state, action) => newState.
Take a data fetcher with four states: idle, loading, success, error. Some transitions are valid, others are not.
type FetchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: string };
type FetchAction =
| { type: 'FETCH' }
| { type: 'RESOLVE'; data: User }
| { type: 'REJECT'; error: string }
| { type: 'RESET' };
function reducer(state: FetchState, action: FetchAction): FetchState {
switch (state.status) {
case 'idle':
if (action.type === 'FETCH') return { status: 'loading' };
return state;
case 'loading':
if (action.type === 'RESOLVE') return { status: 'success', data: action.data };
if (action.type === 'REJECT') return { status: 'error', error: action.error };
return state;
case 'success':
case 'error':
if (action.type === 'RESET') return { status: 'idle' };
return state;
}
}
By switching on state.status first and then on action.type, you express that, for example, RESOLVE only makes sense from loading. An action that does not apply leaves state untouched. This is what makes the pattern resilient: you cannot accidentally end up in a “loading with data” state.
Wire it into a component:
function UserCard({ id }: { id: string }) {
const [state, dispatch] = useReducer(reducer, { status: 'idle' });
useEffect(() => {
let cancelled = false;
dispatch({ type: 'FETCH' });
fetchUser(id)
.then((data) => !cancelled && dispatch({ type: 'RESOLVE', data }))
.catch((err) => !cancelled && dispatch({ type: 'REJECT', error: err.message }));
return () => {
cancelled = true;
};
}, [id]);
if (state.status === 'loading') return <Spinner />;
if (state.status === 'error') return <p>{state.error}</p>;
if (state.status === 'success') return <Profile user={state.data} />;
return null;
}
Because each state variant only carries the fields it needs (no data on loading), TypeScript ensures you cannot read state.data until state.status === 'success'.
Benefits in Practice
Reducers improve testability because they are pure functions. You can write unit tests with no React at all.
test('moves from loading to success', () => {
const before = { status: 'loading' } as const;
const after = reducer(before, { type: 'RESOLVE', data: { id: '1', name: 'A' } });
expect(after).toEqual({ status: 'success', data: { id: '1', name: 'A' } });
});
They also pair well with the Context API. Putting state and dispatch into context lets deep children read state and trigger transitions without prop drilling.
When useState Is Still Better
Do not over-rotate. A single boolean toggle does not need a reducer. A piece of independent local state, like an input value or a “show more” flag, is clearer with useState. Reducers add ceremony, and that ceremony only pays off when the state has structure to manage.
Wrap up
useReducer is useState with a discipline attached. The discipline is that all transitions are described by actions and resolved by a pure function. That makes complex logic easier to read, test, and extend. When state grows beyond a handful of independent variables, particularly when transitions are coordinated, reaching for useReducer (and even a small state machine) is one of the highest-leverage changes you can make.