- Home
- /
- Tutorials
- /
- React Tutorial
- /
- useReducer Explained
State and Reducers
useReducer Explained
A reducer is a pure function from the current state and an action to the next state. All the transition logic ends up in one place you can read top to bottom.
The shape
useReducer(reducer, initialState) returns the current state and a dispatch function. Components describe what happened; the reducer decides what that means.
A counter as a reducer
jsx
function reducer(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: return state
}
}
function Counter() {
const [state, dispatch] = React.useReducer(reducer, { count: 0 })
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "decrement" })}>−</button>{" "}
<button onClick={() => dispatch({ type: "increment" })}>+</button>{" "}
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
)
}
const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Counter />)The buttons no longer contain any logic. They say what happened, and the reducer holds every rule about how state may change.
The reducer must be pure
- Same inputs, same output - no randomness, no dates, no fetching.
- Never mutate
state; return a new object. - No side effects: nothing outside it should change when it runs.
Purity is what makes reducers testable without React at all - call the function with a state and an action and assert on the result. It is also why React can safely call it twice in development.
When it beats useState
Reach for a reducer when transitions get complicated, not when state does. Several fields that change together, or one field whose next value depends on rules, are the signals.
Related fields, one action
jsx
const initial = { status: "idle", user: null, error: null }
function reducer(state, action) {
switch (action.type) {
case "loading": return { status: "loading", user: null, error: null }
case "loaded": return { status: "done", user: action.user, error: null }
case "failed": return { status: "error", user: null, error: action.message }
default: return state
}
}
function Loader() {
const [state, dispatch] = React.useReducer(reducer, initial)
return (
<div>
<p>Status: <strong>{state.status}</strong></p>
{state.user && <p>User: {state.user}</p>}
{state.error && <p style={{ color: "#b91c1c" }}>{state.error}</p>}
<div style={{ display: "flex", gap: 6 }}>
<button onClick={() => dispatch({ type: "loading" })}>Load</button>
<button onClick={() => dispatch({ type: "loaded", user: "Ada" })}>Succeed</button>
<button onClick={() => dispatch({ type: "failed", message: "Network error" })}>Fail</button>
</div>
</div>
)
}
const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Loader />)Three fields always change together, and each transition sets all of them. With three separate useState calls it is possible to have a user and an error at once; here it is not.
Choosing
useState- one value, simple updates. The default.useReducer- several values that move together, or rules about what may follow what.- Both are local state. Neither is global, and swapping one for the other changes nothing about where state lives.
A reducer with one action type is a useState written the long way. Move when the transitions justify it, not before.
