React 05 · 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…
React 05 · 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.
Task is in the comments in the code below – at the top of the file and at the places marked TODO.
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. What each action should do is in the comments in the code.
Medium and hard challenges unlock once you sign in. Like the whole course, they are free – just an e-mail, no password and no payment.
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. What exactly should happen is in the comments in the code.
Medium and hard challenges unlock once you sign in. Like the whole course, they are free – just an e-mail, no password and no payment.
dispatch.useState for small UI things.