useReducer moves every state change into one function, the reducer. Learn actions, dispatch, why reducers must be pure, TypeScript types for actions, and how to test a reducer.
In Part 4 you kept state with useState. In Part 5 you learned to update objects and arrays by making new ones. In Part 6 event handlers changed state when someone clicked.
That works well for small things. But a component can grow many handlers, and each one changes the same state in its own way. Then the rules for that state are spread all over the component. A mistake in one handler is easy to miss.
This part is about useReducer. It is another hook for state. It moves every change into one function, called a reducer. We’ll see the problem first. Then we’ll turn the code into a reducer, step by step. We’ll also see why a reducer must be pure, how TypeScript checks it, and how to test it.
Try this first
In an online shop, a shopping cart is the list of things you plan to buy. Here is a small cart made with useState. Read the code, but don’t press Run yet.
import { useState } from 'react'
type Line = { name: string; qty: number }
export default function App() {
const [cart, setCart] = useState<Line[]>([])
function addOne(name: string) {
if (cart.some(line => line.name === name)) {
setCart(cart.map(line => (line.name === name ? { ...line, qty: line.qty + 1 } : line)))
} else {
setCart([...cart, { name, qty: 1 }])
}
}
function removeOne(name: string) {
setCart(cart.map(line => (line.name === name ? { ...line, qty: line.qty - 1 } : line)))
}
function emptyCart() {
setCart([])
}
return (
<div>
<button onClick={() => addOne('Apple')}>Add an apple</button>
<button onClick={() => removeOne('Apple')}>Remove an apple</button>
<button onClick={() => addOne('Orange')}>Add an orange</button>
<button onClick={emptyCart}>Empty the cart</button>
<p>Lines in the cart: {cart.length}</p>
<ul>
{cart.map(line => (
<li key={line.name}>
{line.name}: {line.qty}
</li>
))}
</ul>
</div>
)
}
Each line of the cart has a name and a qty, short for quantity: how many you want. cart.some(...) is true if at least one line passes the test. The rest is map and spread from Part 5.
Make a guess. You click “Add an apple”, then “Remove an apple”. What does the cart show?
Now press Run and try it.
The cart says “Lines in the cart: 1” and “Apple: 0”. An apple line with nothing in it stays in the cart. Click “Remove an apple” again, and it says “Apple: -1”.
addOne knows a rule: a new name gets a new line. removeOne should know the other half: a line that reaches 0 goes away. Nobody wrote that rule down. The two parts of the rule live in two different handlers, so it was easy to forget one.
When state has rules
The cart has rules. Every name appears once. Every qty is 1 or more. Adding and removing must both follow them.
In the code above, each handler holds part of those rules. To check them all, you must read every handler. In a real app, there might be ten handlers, in different places.
React’s docs describe this problem: “Components with many state updates spread across many event handlers can get overwhelming.”
Their answer is one function, outside the component, that holds all the update code. That function is the reducer.
We’ll move the cart to a reducer in three steps, the same three steps React’s docs use:
- Make each handler say what happened, as an action.
- Write a reducer that works out the next state from each action.
- Use the reducer in the component, with
useReducer.
Step 1: say what happened, with an action
Right now each handler says what to do: “set the cart to this new array”. With a reducer, a handler only says what happened: “the user added an apple”.
It says it with a plain JavaScript object, called an action:
const action = { type: 'added', name: 'Apple' }
You choose what goes in an action. Most people give it a type: a short text that names what happened. Other fields carry the data the change needs. Here, that’s the name. React’s docs suggest names that say what happened, like 'added'. An action with no extra data is just { type: 'emptied' }.
In TypeScript, we list every action the cart can get:
type Action =
| { type: 'added'; name: string }
| { type: 'removed'; name: string }
| { type: 'emptied' }
The | means “or”. You saw it in Part 5, with type Field = 'first' | 'last' | 'email'. So an Action is one of these three objects. A type made with | is called a union.
Step 2: write the reducer
A reducer is a function that takes two things: the current state and an action. It returns the next state. In short: (state, action) => next state.
Here is the cart’s reducer. All the rules of the cart are now in one place:
type Line = { name: string; qty: number }
type Action =
| { type: 'added'; name: string }
| { type: 'removed'; name: string }
| { type: 'emptied' }
function cartReducer(cart: Line[], action: Action): Line[] {
switch (action.type) {
case 'added': {
if (cart.some(line => line.name === action.name)) {
return cart.map(line =>
line.name === action.name ? { ...line, qty: line.qty + 1 } : line,
)
}
return [...cart, { name: action.name, qty: 1 }]
}
case 'removed': {
return cart
.map(line => (line.name === action.name ? { ...line, qty: line.qty - 1 } : line))
.filter(line => line.qty > 0)
}
case 'emptied': {
return []
}
default: {
return cart
}
}
}
A switch checks one value, here action.type, and jumps to the case that matches it. Each case returns the next cart. default is for any type that no case matches. For now it returns the cart as it is. We’ll make it stricter later. React’s docs suggest curly braces around each case’s code, as above. Then variables made in one case can’t get mixed up with those in another. The docs also say a case “should usually end with a return”. Without one, the code goes on into the next case.
Look at 'removed'. After it takes one away, .filter(line => line.qty > 0) drops any line that reached 0. That fixes the bug from “Try this first”.
Note that the reducer didn’t fix the bug by itself. We did. But now the rules for adding and removing sit side by side. So the bug is easier to see.
A reducer is a plain function
A reducer doesn’t use anything from React. You can call it yourself. We called cartReducer by hand:
| We called | It returned |
|---|---|
cartReducer([], { type: 'added', name: 'Apple' }) |
[{ name: 'Apple', qty: 1 }] |
| the same again, on that result | [{ name: 'Apple', qty: 2 }] |
removed Apple, on [{ name: 'Apple', qty: 1 }] |
[] |
emptied, on any cart |
[] |
The cart we passed in never changed. After the second call, the first result still said qty: 1. The reducer made new arrays, as Part 5 taught.
Why it’s called a reducer
JavaScript arrays have a method called reduce. It walks along an array and builds one value from all the items. [1, 2, 3, 4, 5].reduce((sum, n) => sum + n, 0) gives 15. The function you give it gets “the answer so far” and the next item. It returns the next answer.
A React reducer works the same way. It gets the state so far and the next action, and returns the next state. So a list of actions can be “reduced” to one state. We tried it:
type Line = { name: string; qty: number }
type Action = { type: 'added'; name: string } | { type: 'removed'; name: string } | { type: 'emptied' }
declare function cartReducer(cart: Line[], action: Action): Line[]
const actions: Action[] = [
{ type: 'added', name: 'Apple' },
{ type: 'added', name: 'Orange' },
{ type: 'added', name: 'Apple' },
{ type: 'removed', name: 'Orange' },
]
const finalCart = actions.reduce(cartReducer, [])
// [{ name: 'Apple', qty: 2 }]
declare function tells TypeScript “this function exists, with this shape”. It lets this short piece type-check without the whole reducer again. You won’t need it in your own code.
The result was one line: two apples. React’s docs say this “is similar to what React does”.
Step 3: use it with useReducer
Now the component. useReducer takes the reducer and the first state. Like useState, it gives back two things:
import { useReducer } from 'react'
type Line = { name: string; qty: number }
type Action =
| { type: 'added'; name: string }
| { type: 'removed'; name: string }
| { type: 'emptied' }
function cartReducer(cart: Line[], action: Action): Line[] {
switch (action.type) {
case 'added': {
if (cart.some(line => line.name === action.name)) {
return cart.map(line =>
line.name === action.name ? { ...line, qty: line.qty + 1 } : line,
)
}
return [...cart, { name: action.name, qty: 1 }]
}
case 'removed': {
return cart
.map(line => (line.name === action.name ? { ...line, qty: line.qty - 1 } : line))
.filter(line => line.qty > 0)
}
case 'emptied': {
return []
}
default: {
return cart
}
}
}
export default function App() {
const [cart, dispatch] = useReducer(cartReducer, [])
return (
<div>
<button onClick={() => dispatch({ type: 'added', name: 'Apple' })}>Add an apple</button>
<button onClick={() => dispatch({ type: 'removed', name: 'Apple' })}>Remove an apple</button>
<button onClick={() => dispatch({ type: 'added', name: 'Orange' })}>Add an orange</button>
<button onClick={() => dispatch({ type: 'emptied' })}>Empty the cart</button>
<p>Lines in the cart: {cart.length}</p>
<ul>
{cart.map(line => (
<li key={line.name}>
{line.name}: {line.qty}
</li>
))}
</ul>
</div>
)
}
Run it. Add two apples and an orange. Then remove both apples. The apple line goes away, and the cart says “Lines in the cart: 1”.
Look at the two things useReducer gives back:
cartis the current state, like the first item fromuseState. On the first render, it’s the[]we passed in.dispatchis a function. You call it with an action. To dispatch means to send something off. Here, you send an action to React.
The handlers are now one short line each. They don’t know how the cart changes. They only report what the user did. The reducer decides what that means for the state.
The reducer sits outside App, at the top level of the file. It doesn’t need any of App‘s variables, because everything it needs comes in as cart and action. In a bigger app, you can move it to its own file.
What happens when you dispatch
Let’s follow one click on “Add an apple”.
From a click to the page with a reducer. Press play, or step through it.
Here are the same steps in words.
- You click. The handler calls
dispatch({ type: 'added', name: 'Apple' }). - React keeps the action for later. The handler finishes. Inside it,
cartis still[]. - React renders
Appagain. WhenAppcallsuseReducer, React callscartReducer([], { type: 'added', name: 'Apple' }). - The reducer returns
[{ name: 'Apple', qty: 1 }]. useReducergivesAppthat new cart, andAppreturns new JSX.- React commits: it changes the page to show “Apple: 1”.
We checked this order with logs, with Strict Mode off. The handler printed “before dispatch” and then “after dispatch”. The reducer ran only after that, inside the new render of App.
Two things follow from this order, and you know both from Part 4.
State is a snapshot here too. Reading cart right after dispatch gives the old value. We logged cart.length right after a dispatch, and it said 0. The new cart arrives on the next render.
Batching works here too. We made one click call dispatch two times, for an apple and an orange. With Strict Mode off, App rendered once, and the reducer ran twice, once for each action. The page showed both lines.
An everyday example
Think of a bank. You don’t open the bank’s book and change your balance yourself. You fill in a slip: “Put in 10”. You hand the slip to the clerk. The clerk follows the bank’s rules and writes the new balance in the book.
The slip is the action. Handing it over is dispatch. The clerk is the reducer. Only the clerk writes in the book, so every change follows the same rules.
The exact version
The clerk doesn’t work while you stand at the desk. React keeps your slips. It runs the reducer during the next render, one slip at a time, in order. Your handler has already finished by then.
Also, a clerk can make a mess on the desk. A reducer must not. It must only work out the next state. That is the next section.
Reducers must be pure
Part 11 said rendering code must be pure. A pure function only works out its answer from what it was given. It changes nothing that existed before it was called. Anything else it does is a side effect.
A reducer runs while React renders, so it must be pure too. React’s docs say a reducer “must be pure”. That means:
- Same state and same action in, same next state out.
- Don’t change the state you were given. Make new objects and arrays, as in Part 5.
- No side effects. React’s docs say reducers “should not send requests”. They also must not start timers or change anything outside.
Strict Mode runs your reducer twice
To help you find mistakes, Strict Mode calls your reducer twice in development. It keeps one result. React’s docs say “The result from one of the calls is ignored.”
This happens only in development. The docs say it “does not affect production”.
We counted with the cart. One click on “Add an apple” ran the reducer 2 times with Strict Mode on, as in the playground. With it off, 1 time. When the reducer is pure, the two calls give the same answer, and you never notice.
Now see what happens when it isn’t pure. This reducer changes the apple line it was given, then returns a new array:
import { useReducer } from 'react'
type Line = { name: string; qty: number }
type Action =
| { type: 'added'; name: string }
| { type: 'removed'; name: string }
| { type: 'emptied' }
function cartReducer(cart: Line[], action: Action): Line[] {
switch (action.type) {
case 'added': {
const line = cart.find(l => l.name === action.name)
if (line) {
line.qty = line.qty + 1
return [...cart]
}
return [...cart, { name: action.name, qty: 1 }]
}
case 'removed': {
return cart
.map(line => (line.name === action.name ? { ...line, qty: line.qty - 1 } : line))
.filter(line => line.qty > 0)
}
case 'emptied': {
return []
}
default: {
return cart
}
}
}
export default function App() {
const [cart, dispatch] = useReducer(cartReducer, [])
return (
<div>
<button onClick={() => dispatch({ type: 'added', name: 'Apple' })}>Add an apple</button>
<button onClick={() => dispatch({ type: 'removed', name: 'Apple' })}>Remove an apple</button>
<button onClick={() => dispatch({ type: 'added', name: 'Orange' })}>Add an orange</button>
<button onClick={() => dispatch({ type: 'emptied' })}>Empty the cart</button>
<p>Lines in the cart: {cart.length}</p>
<ul>
{cart.map(line => (
<li key={line.name}>
{line.name}: {line.qty}
</li>
))}
</ul>
</div>
)
}
cart.find(...) gives back the first line that passes the test, or undefined if none does.
Run it and click “Add an apple” twice. You expect 2 apples. The page says “Apple: 3”.
line.qty = line.qty + 1 changes a line that is already in state. That is mutation, from Part 5. Strict Mode called the reducer twice with the same old cart. Each call added one to the same line object. So the second click added two.
We clicked 5 times and wrote down the number after each click. With Strict Mode on, it said 1, 3, 5, 7, 9. With it off, it said 1, 2, 3, 4, 5. The bug was there both times. The reducer still changed the old cart. Any code that kept the old cart, like an undo button from Part 5, would now see the wrong number. Strict Mode only made it easy to see.
The fix is the map from the first reducer. It makes a new object for the changed line and never touches the old one.
TypeScript: one type for every action
Our Action type does more than list the actions. It lets TypeScript check each one.
TypeScript knows which action you are in
Every action in the union has a type field with a fixed text. So inside case 'added', TypeScript knows the action is { type: 'added'; name: string }. Inside case 'emptied', it knows there is no name.
Try to read action.name in the wrong case:
type Line = { name: string; qty: number }
type Action =
| { type: 'added'; name: string }
| { type: 'removed'; name: string }
| { type: 'emptied' }
function cartReducer(cart: Line[], action: Action): Line[] {
switch (action.type) {
case 'added': {
return [...cart, { name: action.name, qty: 1 }]
}
case 'removed': {
return cart.filter(line => line.name !== action.name)
}
case 'emptied': {
console.log('Emptied', action.name)
return []
}
}
}
TypeScript stops you with this error: Property 'name' does not exist on type '{ type: "emptied"; }'. Here one field, type, tells the members of the union apart. A union like this is called a discriminated union. To discriminate means to tell things apart.
It checks dispatch too. In an editor, dispatch({ type: 'add', name: 'Apple' }) is an error: Type '"add"' is not assignable to type '"added" | "emptied" | "removed"'. So is dispatch({ type: 'added' }) with no name. The error explains: Property 'name' is missing in type '{ type: "added"; }'.
Making sure every action is handled
Say you add a new action to the union later, but forget to add its case. Will TypeScript notice?
It depends on the default case. With default: return cart, a missing case is not an error. We left out 'emptied', and TypeScript said nothing. The empty-cart button would do nothing at all.
There is a way to make it an error. Write the default case like this:
type Line = { name: string; qty: number }
type Action =
| { type: 'added'; name: string }
| { type: 'removed'; name: string }
| { type: 'emptied' }
function cartReducer(cart: Line[], action: Action): Line[] {
switch (action.type) {
case 'added': {
return [...cart, { name: action.name, qty: 1 }]
}
case 'removed': {
return cart.filter(line => line.name !== action.name)
}
default: {
const missing: never = action
throw new Error('Unknown action: ' + JSON.stringify(missing))
}
}
}
throw new Error(...) stops the code with an error message. JSON.stringify turns the action object into text, so the message shows it.
never is TypeScript’s type for “no value can ever be here”. When every case is handled, no action is left for default. So action really is never there, and the line is fine.
Here 'emptied' is missing. So action could still be { type: 'emptied' } in default. TypeScript reports Type '{ type: "emptied"; }' is not assignable to type 'never'. The error names the action you forgot. Add case 'emptied', and the error goes away. We checked both.
This is called an exhaustive check: nothing is left out. The throw helps in the playground too, which doesn’t check types. If a wrong action arrives anyway, the app stops with a clear message, like Unknown action: {"type":"add","name":"Apple"}.
Testing a reducer without React
A reducer is a plain function, so you can check it like one. You don’t need a browser, a button or a click. Give it a state and an action, and look at what comes back.
This example runs a few checks and shows the results on the page:
type Line = { name: string; qty: number }
type Action =
| { type: 'added'; name: string }
| { type: 'removed'; name: string }
| { type: 'emptied' }
function cartReducer(cart: Line[], action: Action): Line[] {
switch (action.type) {
case 'added': {
if (cart.some(line => line.name === action.name)) {
return cart.map(line =>
line.name === action.name ? { ...line, qty: line.qty + 1 } : line,
)
}
return [...cart, { name: action.name, qty: 1 }]
}
case 'removed': {
return cart
.map(line => (line.name === action.name ? { ...line, qty: line.qty - 1 } : line))
.filter(line => line.qty > 0)
}
case 'emptied': {
return []
}
default: {
return cart
}
}
}
const results: string[] = []
function check(name: string, ok: boolean) {
results.push((ok ? 'pass: ' : 'FAIL: ') + name)
}
const empty: Line[] = []
const one = cartReducer(empty, { type: 'added', name: 'Apple' })
check('adding to an empty cart makes one line', one.length === 1 && one[0].qty === 1)
const two = cartReducer(one, { type: 'added', name: 'Apple' })
check('adding again adds to the same line', two.length === 1 && two[0].qty === 2)
check('the old cart did not change', one[0].qty === 1)
const none = cartReducer(one, { type: 'removed', name: 'Apple' })
check('removing the last apple removes the line', none.length === 0)
export default function App() {
return (
<ul>
{results.map(r => (
<li key={r}>{r}</li>
))}
</ul>
)
}
Run it. All four checks say “pass”.
Now press Edit. Delete the line .filter(line => line.qty > 0) and run it again. The last check says “FAIL: removing the last apple removes the line”. That is the bug from “Try this first”, caught without a single click.
The checks run once, when the code loads, not when App renders. check only adds a line to a list.
React’s docs say a reducer “doesn’t depend on your component”, so you “can export and test it separately”. Real projects use a test runner for this, a tool that runs many checks and reports which fail. Part 49 covers testing.
The first state, and the third argument
The second argument of useReducer is the first state. React uses it only on the first render. After that, the state comes from the reducer.
Sometimes the first state takes work to build. Say it comes from a list of names:
type Line = { name: string; qty: number }
function makeCart(names: string[]): Line[] {
console.log('makeCart runs')
return names.map(name => ({ name, qty: 1 }))
}
If you write useReducer(cartReducer, makeCart(['Apple', 'Orange'])), then makeCart runs on every render. The answer is thrown away each time after the first. useReducer can take a third argument to avoid that:
import { useReducer } from 'react'
type Line = { name: string; qty: number }
type Action =
| { type: 'added'; name: string }
| { type: 'removed'; name: string }
| { type: 'emptied' }
function cartReducer(cart: Line[], action: Action): Line[] {
switch (action.type) {
case 'added': {
if (cart.some(line => line.name === action.name)) {
return cart.map(line =>
line.name === action.name ? { ...line, qty: line.qty + 1 } : line,
)
}
return [...cart, { name: action.name, qty: 1 }]
}
case 'removed': {
return cart
.map(line => (line.name === action.name ? { ...line, qty: line.qty - 1 } : line))
.filter(line => line.qty > 0)
}
case 'emptied': {
return []
}
default: {
return cart
}
}
}
function makeCart(names: string[]): Line[] {
console.log('makeCart runs')
return names.map(name => ({ name, qty: 1 }))
}
export default function App() {
const [cart, dispatch] = useReducer(cartReducer, ['Apple', 'Orange'], makeCart)
return (
<div>
<button onClick={() => dispatch({ type: 'added', name: 'Apple' })}>Add an apple</button>
<ul>
{cart.map(line => (
<li key={line.name}>
{line.name}: {line.qty}
</li>
))}
</ul>
</div>
)
}
makeCart runs
makeCart runs
Now React calls makeCart(['Apple', 'Orange']) itself, only for the first render. It is the same idea as useState(makeList) in Part 4. Note that you pass makeCart, the function, not makeCart(...), its answer. A function like this, which makes the first state, is called an initializer.
Run it and click “Add an apple” twice. The Console shows makeCart runs two times, both on the first render. That is Strict Mode again: it calls the initializer twice, like the reducer. The clicks added no more lines. We also tried makeCart(...) as the second argument. With Strict Mode off, it ran once on the first render and once more for each click.
You need this only when building the first state is slow. Most of the time, a plain value is fine.
dispatch never changes
dispatch is the same function on every render. React’s docs say it “has a stable identity”. We checked with this code:
import { useReducer, useRef } from 'react'
type Line = { name: string; qty: number }
type Action =
| { type: 'added'; name: string }
| { type: 'removed'; name: string }
| { type: 'emptied' }
function cartReducer(cart: Line[], action: Action): Line[] {
switch (action.type) {
case 'added': {
if (cart.some(line => line.name === action.name)) {
return cart.map(line =>
line.name === action.name ? { ...line, qty: line.qty + 1 } : line,
)
}
return [...cart, { name: action.name, qty: 1 }]
}
case 'removed': {
return cart
.map(line => (line.name === action.name ? { ...line, qty: line.qty - 1 } : line))
.filter(line => line.qty > 0)
}
case 'emptied': {
return []
}
default: {
return cart
}
}
}
export default function App() {
const [cart, dispatch] = useReducer(cartReducer, [])
const firstDispatch = useRef(dispatch)
function handleAdd() {
console.log('Same dispatch as the first render?', firstDispatch.current === dispatch)
dispatch({ type: 'added', name: 'Apple' })
}
return (
<div>
<button onClick={handleAdd}>Add an apple</button>
<p>Apples: {cart.length > 0 ? cart[0].qty : 0}</p>
</div>
)
}
Same dispatch as the first render? true
Same dispatch as the first render? true
Same dispatch as the first render? true
useRef(dispatch) keeps the dispatch from the first render, as Part 14 showed. Each click compares it with the dispatch of the latest render. Click three times, and each line says true. These lines are not doubled, because React never calls a click handler twice.
This helps later. In Part 20, React.memo lets a child skip its render when its props are the same as last time. dispatch always is. We tried it with Strict Mode off. A parent rendered 3 more times. A memo child that got dispatch as a prop ran 0 more times. One that got a new arrow function, () => dispatch(...), ran 3 more times. Part 21 shows useCallback, which you’d need to keep an arrow function the same without the React Compiler (Part 22). With dispatch, you don’t need it.
The same goes for Effects. React’s docs say you will often see dispatch left out of an Effect’s dependency array. Putting it in does no harm. Because it never changes, it never makes the Effect run again.
The set function from useState is stable in the same way. Part 4 checked it.
useState or useReducer?
Both hooks keep state. React’s docs say “they are equivalent”. Anything you can do with one, you can do with the other. The docs compare them in five ways:
- Code size.
useStateneeds less code at first. A reducer needs the reducer and the actions. But a reducer can save code when many handlers change state in similar ways. - Reading the code.
useStateis easy to read when updates are simple. When they get complex, a reducer helps. It keeps “what happened” in the handlers, and “how state changes” in one place. - Finding bugs. Put one
console.login a reducer, and you see every update. You also see which action caused it. In development, Strict Mode shows each one twice. - Testing. A reducer doesn’t depend on your component, so you can test it alone, as above.
- What you like. The docs say “Some people like reducers, others don’t. That’s okay.”
Their advice: use a reducer when a component often has bugs from wrong state updates. A reducer gives its code more order. You can also use both in one component. Keep a text box’s text in useState, and the cart in useReducer.
Here is a short summary:
Use useState when |
Use useReducer when |
|---|---|
| a value is changed in one or two simple ways | the same state changes in many ways |
| each value is changed on its own | several values must change together |
| the next value is easy to work out | the rules are worth testing on their own |
You will meet reducers again. Part 24, on the provider pattern, shares state with many components at once. There is also a small library called use-immer. Its useImmerReducer lets you write a reducer in the “change it in place” style. It then makes the new objects for you. Part 5 named Immer. The playground can only load React, so we won’t use it here.
Common mistakes
Changing the state inside the reducer
You saw this above: line.qty = line.qty + 1 gave 3 apples after two clicks. There is a second form. Say you change the state and return the same array. React compares it with Object.is, sees no change, and skips the update. We tried it: the second click on “Add an apple” left the page at “Apple: 1”.
Note one thing about “skips”. The reducer runs while App renders, so React has already called App by the time it sees the same array. We counted: with Strict Mode off, each such click called App once, and its child 0 times. React’s docs say React “may still need to call your component before ignoring the result”. In Part 4, setCount(count) right after the page loaded called App 0 times. That is because useState can sometimes compare the value before rendering. A reducer only runs during the render.
The fix is always the same. Return new objects and arrays, with spread, map and filter, as in Part 5.
No return for an unknown action
A typo is a typing mistake. Here an action has a typo, and the reducer has no default:
import { useReducer } from 'react'
type Line = { name: string; qty: number }
type Action =
| { type: 'added'; name: string }
| { type: 'removed'; name: string }
| { type: 'emptied' }
function cartReducer(cart: Line[], action: Action): Line[] {
switch (action.type) {
case 'added': {
return [...cart, { name: action.name, qty: 1 }]
}
case 'removed': {
return cart.filter(line => line.name !== action.name)
}
case 'emptied': {
return []
}
}
}
export default function App() {
const [cart, dispatch] = useReducer(cartReducer, [])
return (
<div>
<button onClick={() => dispatch({ type: 'add', name: 'Apple' })}>Add an apple</button>
<p>Lines in the cart: {cart.length}</p>
</div>
)
}
The button sends 'add', but the reducer knows 'added'. In an editor, TypeScript marks the typo.
The playground doesn’t check types, so press Run and click. No case matches. The function ends without a return, so the reducer gives back undefined. The next state is undefined, and cart.length throws an error. Our test used the same JavaScript engine as Chrome, and it said Cannot read properties of undefined (reading 'length'). Other browsers word it differently. The app stops.
Fix the typo. Then make sure the reducer always returns something. Either default: return cart, or the never check with throw, which also tells you which action was wrong. With default: return cart, the same typo just does nothing, and nothing tells you why.
Side effects in the reducer
This reducer talks to a server when someone orders. Here sendOrder pretends to be the server: it only counts and logs.
import { useReducer } from 'react'
type State = { orders: number }
type Action = { type: 'ordered' }
let sent = 0
function sendOrder() {
sent = sent + 1
console.log('The server got order number', sent)
}
function shopReducer(state: State, action: Action): State {
switch (action.type) {
case 'ordered': {
sendOrder()
return { orders: state.orders + 1 }
}
default: {
return state
}
}
}
export default function App() {
const [state, dispatch] = useReducer(shopReducer, { orders: 0 })
return (
<div>
<button onClick={() => dispatch({ type: 'ordered' })}>Order</button>
<p>Orders: {state.orders}</p>
</div>
)
}
The server got order number 1
The server got order number 2
Run it and click “Order” once. The page says “Orders: 1”. But the server got two orders. Strict Mode called the reducer twice, and each call sent an order. With Strict Mode off, we saw one. But the bug is still there. A reducer that sends orders is not pure, and React’s rules say it must be.
Put the side effect in the event handler. React never calls an event handler twice:
import { useReducer } from 'react'
type State = { orders: number }
type Action = { type: 'ordered' }
let sent = 0
function sendOrder() {
sent = sent + 1
console.log('The server got order number', sent)
}
function shopReducer(state: State, action: Action): State {
switch (action.type) {
case 'ordered': {
return { orders: state.orders + 1 }
}
default: {
return state
}
}
}
export default function App() {
const [state, dispatch] = useReducer(shopReducer, { orders: 0 })
return (
<div>
<button
onClick={() => {
sendOrder()
dispatch({ type: 'ordered' })
}}
>
Order
</button>
<p>Orders: {state.orders}</p>
</div>
)
}
The server got order number 1
Now one click sends one order. The same goes for timers, saving to localStorage, and anything random. Math.random() or Date.now() in a reducer gives a different answer each time, so the reducer is no longer pure. Make the random id or the time in the handler, and put it in the action.
One giant reducer for everything
You might put all of an app’s state in one reducer. The cart, the user, the menu and the search box all go in. Then every action goes through one long switch. Most of its cases have nothing to do with each other.
A reducer is for state that changes together and follows the same rules. Keep state that has nothing in common apart. A component can call useReducer more than once, and mix it with useState.
Also, keep each action about one thing the user did. React’s docs give an example: a “Reset” button on a form with five fields. Send one reset_form action, not five separate set_field actions. Then a log of actions reads like a story of what the user did.
Practice
Press Edit on the examples above and try these.
- In the
useReducercart, add a button “Remove all apples”. Add a new action,{ type: 'removedAll'; name: string }, and its case. - In the same cart, put
console.log('reducer:', action.type)as the first line ofcartReducer. Run it and click “Add an apple” once. How many lines does the Console show? Why? - In the testing example, add a check that
emptiedgives an empty cart. - In the example where the reducer changes the old line (
line.qty = line.qty + 1), click “Add an apple” three times. What number do you see? What would you see with Strict Mode off?
Answers
- Add
| { type: 'removedAll'; name: string }toAction. Add this case:case 'removedAll': { return cart.filter(line => line.name !== action.name) }. Add the button:<button onClick={() => dispatch({ type: 'removedAll', name: 'Apple' })}>Remove all apples</button>. After two apples and an orange, the new button leaves “Orange: 1”. - Two lines, both
reducer: added. Strict Mode calls the reducer twice for each action, in development. Nothing is logged when the page first loads. React doesn’t call the reducer to get the first state. - Add
const gone = cartReducer(two, { type: 'emptied' })andcheck('emptying gives an empty cart', gone.length === 0), beforeApp. The page shows a fifth line, “pass: emptying gives an empty cart”. - “Apple: 5”. The first click adds the line with 1. Each later click adds 2, because Strict Mode runs the reducer twice on the same old line. With Strict Mode off, you’d see 3. The reducer would still be wrong. You just wouldn’t see it yet.
Interview questions
Try to answer each one out loud before you open the answer.
What is a reducer, and how does useReducer use it?
A reducer is a function that takes the current state and an action, and returns the next state: (state, action) => next state. useReducer(reducer, initialState) returns the current state and a dispatch function. A handler calls dispatch(action). On the next render, React runs the reducer with the current state and that action. The component gets the result.
A strong answer explains the name: it is the same idea as the function you pass to Array.prototype.reduce. It also says the reducer runs during rendering, not inside dispatch. So the state you read right after dispatch is still the old one.
When would you choose useReducer over useState?
When one piece of state changes in many ways, from many handlers, and those changes follow rules. Or when several values must change together. A reducer puts every update in one place. Each change gets a named action. And you can test it without React.
A strong answer adds that the two are equivalent, so this is a choice, not a rule. useState is less code for simple values. React’s docs compare them on code size, reading the code, finding bugs, testing, and what each person likes. You can use both in one component.
Why must a reducer be pure? What happens if it isn’t?
React calls the reducer while it renders, and it may call it more than once. In development, Strict Mode calls it twice and keeps one result. A pure reducer gives the same answer both times. If it changes the old state, the change happens twice. In our test, an “add” that changed the old line showed 3 apples after two clicks. If it sends a request, the request goes out twice.
A strong answer says where side effects go instead: in the event handler that dispatches, or in an Effect. It also names hidden side effects, like Math.random() or Date.now() inside the reducer. Make those values in the handler and pass them in the action.
Does dispatch change on every render? Why does it matter?
No. React’s docs say dispatch “has a stable identity”. It is the same function on every render. So you can pass it to a child wrapped in React.memo, and dispatch alone won’t make the child render again. You also don’t need useCallback for it. And it’s safe in an Effect’s dependency array: it never makes the Effect run again.
A strong answer adds that dispatch is often passed down instead of many callback props. One function can send any action, so a child doesn’t need a separate handler for each change.
How do you type actions in TypeScript, and how do you make sure every action is handled?
Write the actions as a union of object types, each with its own type text: { type: 'added'; name: string } | { type: 'emptied' }. This is a discriminated union. Inside case 'added', TypeScript knows the action has a name. A typo in a type, or a missing field, is an error at the dispatch call.
To make sure every case is handled, put the action in a variable of type never in default: const missing: never = action. Say you add an action and forget its case. TypeScript then reports an error that names the new action.
A strong answer also throws in that default. Then a bad action at run time stops with a clear message, instead of quietly doing nothing.
How is useReducer related to Redux?
Redux is a popular library for app-wide state. It uses the same idea. Redux’s docs describe a reducer as (state, action) => newState, and it also sends actions with a dispatch function. So if you know useReducer, the core of Redux looks familiar.
The difference is where the state lives. useReducer state belongs to one component on the page, like useState. Redux keeps the state of the whole app in one object, called the store. The store lives outside your components.
A strong answer adds more differences:
useReducerstate is removed when its component leaves the page. We checked: a counter that reached 2 showed 0 after it was hidden and shown again. A Redux store keeps its state.- Redux splits a big state into smaller reducers and joins them with
combineReducers. - Redux reducers can’t have side effects either. Redux’s docs put them in middleware, like the
thunkmiddleware. Middleware is code that sits arounddispatch. - Redux Toolkit’s
createSliceuses Immer inside, likeuseImmerReducer. So you may see Redux reducers that seem to change state in place. - Sharing a
useReducerwith context (Part 24) is not the same as Redux. React’s docs say React “re-renders all the children that use a particular context” when its value changes. Redux’suseSelectorre-renders a component only when the part it reads changes.
How would you test a reducer?
Call it like any function. Give it a state and an action, and check what comes back. No component, browser or click is needed. Check the main cases and the edge cases, like removing the last item. Also check that the old state didn’t change.
A strong answer says this is one of the main reasons to use a reducer. It also says that tests of the reducer don’t replace tests of the component. A test runner and React Testing Library check the whole component, as a user would use it.
Sources
- useReducer, react.dev: the parameters and the initializer, “must be pure”, “has a stable identity”, the snapshot after
dispatch,Object.isskipping an update, a missingreturngivingundefined, and “call your reducer and initializer twice” in Strict Mode. - Extracting State Logic into a Reducer, react.dev: the three steps, action names,
switchwith braces, the name “reducer” andreduce, the five-way comparison withuseState, “Reducers must be pure”, one action per user interaction, anduseImmerReducer. - StrictMode, react.dev: Strict Mode calls functions passed to
useReducertwice in development. - Keeping Components Pure, react.dev: what a pure function is.
- Scaling Up with Reducer and Context, react.dev: putting state and
dispatchinto context, for Part 24. - useState, react.dev: the set function’s stable identity.
- Narrowing, TypeScript Handbook: discriminated unions, the
nevertype and exhaustiveness checking. - Redux Concepts and Data Flow, Redux docs: a reducer as
(state, action) => newState. - use-immer, README:
useImmerReducer. - The page text, the call counts, the order of events, the 1, 3, 5, 7, 9 apples, the two orders, the
memocounts and the TypeScript errors above come from running React 19.3.0 and TypeScript 7.0.2 for this post. - This part follows the useReducer kata in react-katas.