Lesson 5 · Hooks in depth
More complex state (useReducer)
When to reach for a reducer instead of useState and how to write it cleanly and type-safely.
Loading lesson…
Lesson 5 · Hooks in depth
When to reach for a reducer instead of useState and how to write it cleanly and type-safely.
Loading lesson…
Imagine a bank worked like this: any member of staff can open your account and overwrite the balance with a number they've worked out themselves. The cashier at the counter, the mobile app, the cash machine, the fee robot – each calculates it slightly differently. Once the balance doesn't add up, nobody can find out why.
A real bank does it differently. Nobody overwrites the balance. Everyone just records what happened: "deposit $500", "card payment $120", "monthly fee". These are called transactions. And the new balance is calculated by one place following fixed rules: add a deposit, subtract a payment, and when there's nothing in the account, decline the payment.
This approach has big advantages:
useReducer brings exactly this approach to React. Components don't overwrite state, they just report actions (transactions). The new state is calculated by the reducer – a function with the rules.
We'll start with a task list written the way you know from the lesson on state:
function TaskList() {const [tasks, setTasks] = useState<Task[]>([])function handleAdd(text: string) {setTasks([...tasks, { id: nextId++, text, done: false }])}function handleToggle(id: number) {setTasks(tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)))}function handleDelete(id: number) {setTasks(tasks.filter((t) => t.id !== id))}function handleClearDone() {setTasks(tasks.filter((t) => !t.done))}// …JSX that calls these functions}
It works. But the rules for how the list changes are scattered across four functions – each "overwrites the balance" in its own way. Once there are twenty of them spread over several components, it's hard to find out who changed what. Let's rewrite it as a reducer in three steps.
Each function above corresponds to one event. We'll describe them as actions – ordinary objects with a type property and the data that belongs to the event:
type Action =| { type: 'added'; text: string }| { type: 'toggled'; id: number }| { type: 'deleted'; id: number }| { type: 'cleared_done' }
Actions are transactions: "task added, text: Go shopping". They don't say how the array should change – only what happened.
A reducer gets the current state and an action and returns the new state. All the logic from the four handlers moves here:
function tasksReducer(tasks: Task[], action: Action): Task[] {switch (action.type) {case 'added':return [...tasks, { id: nextId++, text: action.text, done: false }]case 'toggled':return tasks.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t))case 'deleted':return tasks.filter((t) => t.id !== action.id)case 'cleared_done':return tasks.filter((t) => !t.done)}}
Notice that it's an ordinary function, nothing from React. The rules about mutation from the lesson on state still apply – you always return a new array.
function TaskList() {const [tasks, dispatch] = useReducer(tasksReducer, [])// instead of handleToggle(id):<input type="checkbox" onChange={() => dispatch({ type: 'toggled', id: task.id })} />// instead of handleAdd(text):<form onSubmit={() => dispatch({ type: 'added', text })}>}
useReducer(tasksReducer, []) is like useState([]), except instead of a setter it returns dispatch – "the counter window where transactions are handed in".dispatch(action) calculates nothing. It passes the action to React, which calls the reducer.dispatch({ type: "toggled", id: 2 }).tasksReducer(currentTasks, { type: "toggled", id: 2 }).'toggled' branch and returns a new array in which task 2 has done flipped.What you see: The task list from the example. On the right is an action log – "the account statement". Every dispatch is written into it before the reducer processes it.
Try it:
{"type":"added","text":"Cook lunch"}.cleared_done action removed several tasks at once – only the reducer knows the rule for "what should be deleted".The takeaway: The component just reports what happened. How the state changes is decided by one place – and the action log is a clear story. This is exactly how Redux DevTools work.
Loading the interactive part…
Not everything needs a bank. You don't track the change in your own pocket through transactions either – you just know how much is there. It's the same with state: simple, independent values are clearer with useState.
| useState (change in your pocket) | useReducer (a bank account) |
|---|---|
| Simple, independent values (an input’s text, open/closed) | Several values that change together |
| A few ways to change the state | Many different actions on the same data |
| You easily calculate the new state in the handler | The new state depends on the previous one in complex ways (validation, transitions) |
| — | You want to test the logic separately or share it through Context |
A washing machine has programmes: ready → washing → spinning → done. It can't be "washing" and "done" at the same time. And while it's washing, the Start button does nothing – there's nowhere to go. Such a device is called a state machine: it has a few clearly named states and rules for moving between them.
Loading data is often written as a set of independent values:
// ❌ Three switches = eight combinations, but only a few make senseconst [isLoading, setIsLoading] = useState(false)const [error, setError] = useState<string | null>(null)const [user, setUser] = useState<User | null>(null)// isLoading && error && user? A washing machine that's washing, done and broken all at once.
As a state machine it looks like this. Every state carries only the data that belongs to it:
type State =| { status: 'idle' } // ready| { status: 'loading' } // washing| { status: 'success'; user: User } // done – and only here is there a user| { status: 'error'; error: string } // broken – and only here is there an errortype Action =| { type: 'fetch' }| { type: 'resolve'; user: User }| { type: 'reject'; error: string }function reducer(state: State, action: Action): State {switch (action.type) {case 'fetch':// A transition rule: while it's already loading, another "Start" changes nothingreturn state.status === 'loading' ? state : { status: 'loading' }case 'resolve':return { status: 'success', user: action.user }case 'reject':return { status: 'error', error: action.error }}}
And where's the loading itself? Outside the reducer, in a handler. The reducer only gets the results as actions:
async function load(id: number) {dispatch({ type: 'fetch' }) // washing machine: washingtry {const user = await fetchUser(id)dispatch({ type: 'resolve', user }) // washing machine: done} catch (e) {dispatch({ type: 'reject', error: (e as Error).message }) // washing machine: broken}}
TypeScript then makes sure in the JSX that you can touch state.user only where you've checked state.status === 'success'. A nonsensical combination can't even be written.
What you see: The reducer from the example above. The card shows content according to the state, below it is the current status. The users load from the fake API with a delay.
Try it:
idle → loading → success and the card shows the name.loading to error and the card shows the error. The name from before has disappeared – there's no user in the error state.fetch – like a washing machine that's already washing.idle.The takeaway: The state is always in exactly one of four named states and carries only the data that belongs to it. The reducer guards the transitions.
Loading the interactive part…
A bank calculates the balance by the rules and does nothing else while doing it – it doesn't call clients or send letters. If it did, every recalculation (during an audit, say) would send another letter. It's the same with a reducer: React can call it several times, and in development mode it does so on purpose.
// ❌ An impure reducercase 'added':saveToServer(action.text) // an API callreturn [...tasks, { id: Math.random(), ... }] // randomness – a different result each time// ✅ A pure reducer: the same state and action always give the same resultcase 'added':return [...tasks, { id: action.id, text: action.text, done: false }]// create the ID in the handler and send it in the action; call the API in the handler or in an effect
Math.random(), Date.now() or mutations. Whatever is random or asynchronous happens outside and arrives in the reducer in an action.'item_added' is better than 'set_items'. A "deposit 500" transaction, not "set the balance to 1500".default branch put const x: never = action. When you add a new action and forget to handle it, TypeScript warns you.The reducer is missing a single action. Add it and the switch starts working.
CHALLENGE: A light switch
The reducer already handles the 'off' action. The 'toggle' action is missing.
1. In the reducer, add case 'toggle': return { on: !state.on }
The buttons already send the actions (dispatch).row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).Loading the interactive part…
The wizard practises sections 1 and 3: all the transition rules (validation, next/back) belong in the reducer, the component just sends actions. Pixel art adds history: when you have all the changes as actions, undo/redo is surprisingly simple.
Step by step like a washing machine: you can't get from step 1 to step 2 until the reducer confirms the details are fine.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
The classic history pattern (past/present/future) used by editors, spreadsheets and Redux-undo. The selected colour isn't in the history – that's change in your pocket.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
dispatch.useState for small UI things.