React useReducer
useReducer manages state transitions through a pure reducer function and dispatched action objects, making complex or interrelated state updates easier to follow than a pile of useState calls.
From useState to a Reducer
useState works well for independent pieces of state, but once several values change together in response to the same event — like a form that tracks values, errors, and submission status — you end up writing the same update logic in multiple places. useReducer centralizes that logic into a single function that takes the current state and an action, and returns the next state.
The Shape of a Reducer
A reducer is a pure function: (state, action) => newState. It never mutates the existing state object, never causes side effects, and always returns a full replacement for state given the same inputs. Actions are typically plain objects with a type field describing what happened, plus whatever payload the reducer needs to compute the next state.
- type — a string describing the event, e.g. 'increment' or 'field_changed'
- payload data — any extra values the reducer needs (naming is a convention, not a rule)
- The reducer switches on action.type and returns a new state object for each case
- An unrecognized action.type should return the existing state unchanged, or throw to catch typos early
A Counter With useReducer
import { useReducer } from 'react';
function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
export default Counter;
A Reducer for a Multi-Field Form
import { useReducer } from 'react';
const initialForm = {
values: { name: '', email: '' },
errors: {},
submitted: false,
};
function formReducer(state, action) {
switch (action.type) {
case 'field_changed':
return {
...state,
values: { ...state.values, [action.field]: action.value },
};
case 'validation_failed':
return { ...state, errors: action.errors };
case 'submit_succeeded':
return { ...state, errors: {}, submitted: true };
default:
return state;
}
}
function SignupForm() {
const [state, dispatch] = useReducer(formReducer, initialForm);
function handleChange(e) {
dispatch({
type: 'field_changed',
field: e.target.name,
value: e.target.value,
});
}
return (
<form>
<input name="name" value={state.values.name} onChange={handleChange} />
<input name="email" value={state.values.email} onChange={handleChange} />
{state.submitted && <p>Thanks for signing up!</p>}
</form>
);
}
export default SignupForm;
Lazy Initialization With a Third Argument
import { useReducer, useEffect } from 'react';
function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
function init(initialCount) {
// Runs once, on mount only — useful for expensive setup
// like reading from localStorage.
const saved = window.localStorage.getItem('count');
return { count: saved ? Number(saved) : initialCount };
}
function PersistentCounter({ initialCount }) {
const [state, dispatch] = useReducer(counterReducer, initialCount, init);
useEffect(() => {
window.localStorage.setItem('count', state.count);
}, [state.count]);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</div>
);
}
export default function App() {
return <PersistentCounter initialCount={0} />;
}
Exercise: React useReducer
What two arguments does a reducer function receive?