This site runs best with JavaScript enabled.

Should I useState or useReducer?

Photo by Kyle Glenn on Unsplash


Two built-in React hooks that handle state, which one should you use?

Watch "When to useState instead of useReducer" on egghead.io

Watch "When to useReducer instead of useState" on egghead.io

Whenever there are two things to do the same thing, people inevitably ask: "When do I use one over the other?" There are two possible reasons for having multiple ways of doing the same thing:

  1. One is the "old way" and the other is the "new (and improved) way". Typically the old way is kept around for backward compatibility reasons and the new way is the path forward for new code. For example: class components (old way) vs function components (new way).
  2. They come with different trade-offs that should be considered and therefore should be applied in situations that suit them better (sometimes meaning you'll use more than one in a given application). For example: useEffect vs useLayoutEffect or Static vs Unit vs Integration vs E2E tests or "Control Props" vs "State Reducers"

useState and useReducer fall into the second category here. So let's talk about the trade-offs.

(and no, it's not a trick question 😂).

Examples

I think the best way to discuss these trade-offs is through the lens of examples. We'll look at two examples. One which suits useState better, and one which suits useReducer better. This won't be enough to cover all of the trade-offs, but hopefully can be a good starting point for us.

Custom useDarkMode hook

I recently wrote this for my workshop projects (for example). It's pretty interesting, so let's compare useState and useReducer implementations for that:

useState implementation

I'll highlight the areas where we're interacting with the state:

1function useDarkMode() {
2 const preferDarkQuery = '(prefers-color-scheme: dark)'
3 const [mode, setMode] = React.useState(
4 () =>
5 window.localStorage.getItem('colorMode') ||
6 (window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
7 )
8
9 React.useEffect(() => {
10 const mediaQuery = window.matchMedia(preferDarkQuery)
11 const handleChange = () => setMode(mediaQuery.matches ? 'dark' : 'light')
12 mediaQuery.addListener(handleChange)
13 return () => mediaQuery.removeListener(handleChange)
14 }, [])
15
16 React.useEffect(() => {
17 window.localStorage.setItem('colorMode', mode)
18 }, [mode])
19
20 return [mode, setMode]
21}

Hopefully that makes sense. Basically we're saying, if the user's set their preferences to dark mode, then we'll initialize our mode to dark, otherwise we'll initialize to light and then return the mode and setMode and as the mode changes (whether by calling setMode directly or as the user changes their system preferences) we'll keep that value set in localStorage for future use.

useReducer implementation:

There are several ways you could write this with useReducer. I'll start out with the typical way most people write reducers:

1const preferDarkQuery = '(prefers-color-scheme: dark)'
2
3function darkModeReducer(state, action) {
4 switch (action.type) {
5 case 'MEDIA_CHANGE': {
6 return {...state, mode: action.mode}
7 }
8 case 'SET_MODE': {
9 // make sure to spread that state just in case!
10 return {...state, mode: action.mode}
11 }
12 default: {
13 // helps us avoid typos!
14 throw new Error(`Unhandled action type: ${action.type}`)
15 }
16 }
17}
18
19// use the init function to lazily initialize state so we don't read into
20// localstorage or call matchMedia every render
21function init() {
22 return {
23 mode:
24 window.localStorage.getItem('colorMode') ||
25 (window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
26 }
27}
28
29function useDarkMode() {
30 const [state, dispatch] = React.useReducer(
31 darkModeReducer,
32 {mode: 'light'},
33 init,
34 )
35 const {mode} = state
36
37 React.useEffect(() => {
38 const mediaQuery = window.matchMedia(preferDarkQuery)
39 const handleChange = () =>
40 dispatch({
41 type: 'MEDIA_CHANGE',
42 mode: mediaQuery.matches ? 'dark' : 'light',
43 })
44 mediaQuery.addListener(handleChange)
45 return () => mediaQuery.removeListener(handleChange)
46 }, [])
47
48 React.useEffect(() => {
49 window.localStorage.setItem('colorMode', mode)
50 }, [mode])
51
52 // We like the API the way it is, so instead of returning the state object
53 // and the dispatch function, we'll return the `mode` property and we'll
54 // create a setMode helper (which we have to memoize in case someone wants
55 // to use it in a dependency list):
56 const setMode = React.useCallback(
57 newMode => dispatch({type: 'SET_MODE', mode: newMode}),
58 [],
59 )
60
61 return [mode, setMode]
62}

Wow, I think we can both agree that the useState version was WAY simpler! But wait! We can drastically simplify the useReducer version by going against the "grain" and not writing your typical redux-style reducer. Let's try that:

1function useDarkMode() {
2 const preferDarkQuery = '(prefers-color-scheme: dark)'
3 const [mode, setMode] = React.useReducer(
4 (prevMode, nextMode) =>
5 typeof nextMode === 'function' ? nextMode(prevMode) : nextMode,
6 'light',
7 () =>
8 window.localStorage.getItem('colorMode') ||
9 (window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
10 )
11
12 React.useEffect(() => {
13 const mediaQuery = window.matchMedia(preferDarkQuery)
14 const handleChange = () => setMode(mediaQuery.matches ? 'dark' : 'light')
15 mediaQuery.addListener(handleChange)
16 return () => mediaQuery.removeListener(handleChange)
17 }, [])
18
19 React.useEffect(() => {
20 window.localStorage.setItem('colorMode', mode)
21 }, [mode])
22
23 return [mode, setMode]
24}

That's a lot better than our other try with useReducer. But we basically implemented useState with useReducer. And even then, it's still less clear than our useState version. So at the end of the day, useState was a much better solution in this case.

When it's just an independent element of state you're managing: useState

Custom useUndo hook

There's a great useUndo hook on GitHub. I took inspiration from that for this example. Thank you Homer Chen!

(For these, almost everything is interacting with state, so... no highlights...)

useState implementation

1function useUndo(initialPresent) {
2 const [past, setPast] = React.useState([])
3 const [present, setPresent] = React.useState(initialPresent)
4 const [future, setFuture] = React.useState([])
5
6 const canUndo = past.length !== 0
7 const canRedo = future.length !== 0
8
9 const undo = React.useCallback(() => {
10 if (!canUndo) return
11
12 const previous = past[past.length - 1]
13 const newPast = past.slice(0, past.length - 1)
14
15 setPast(newPast)
16 setPresent(previous)
17 setFuture([present, ...future])
18 }, [canUndo, future, past, present])
19
20 const redo = React.useCallback(() => {
21 if (!canRedo) return
22
23 const next = future[0]
24 const newFuture = future.slice(1)
25
26 setPast([...past, present])
27 setPresent(next)
28 setFuture(newFuture)
29 }, [canRedo, future, past, present])
30
31 const set = React.useCallback(
32 newPresent => {
33 if (newPresent === present) {
34 return
35 }
36 setPast([...past, present])
37 setPresent(newPresent)
38 setFuture([])
39 },
40 [past, present],
41 )
42
43 const reset = React.useCallback(newPresent => {
44 setPast([])
45 setPresent(newPresent)
46 setFuture([])
47 }, [])
48
49 return [
50 {past, present, future},
51 {set, reset, undo, redo, canUndo, canRedo},
52 ]
53}

Looks pretty ok right? It probably is, but there's actually a situation where this could be pretty buggy. But first, I want to address a common misconception people have about calling multiple state updaters in sequence (like we're doing in each of those functions).

Often people think this means that you'll trigger a re-render for each call (so, they're suggesting that calling reset would trigger three rerenders). First, remember to focus on Fixing the slow render before you fix the re-render, but secondly, remember that React has a batch system so if you were to call reset from an event handler or in a useEffect callback, it would trigger only one re-render.

That said, if we were to call reset in an async function (like after making an HTTP request), then that would result in three re-renders. However, in the future with concurrent mode those will be batched as well. So my main concern isn't the re-renders.

My concern is with the insidious stale closure bugs in our code! Can you spot them? There are three! I'll give you a hint, there's one in each of undo, redo, and set, but there's not one in reset.

Here's a contrived example that would reveal this bug:

1function Example() {
2 const [state, {set}] = useUndo('first')
3
4 React.useEffect(() => {
5 set('second')
6 }, [])
7
8 React.useEffect(() => {
9 set('third')
10 }, [])
11
12 return <pre>{JSON.stringify(state, null, 2)}</pre>
13}

The printed result here would be:

1{
2 "past": ["first"],
3 "present": "third",
4 "future": []
5}

It should be:

1{
2 "past": ["first", "second"],
3 "present": "third",
4 "future": []
5}

So what happened to "second" in our situation? Ah! Turns out we're missing our dependency on set in the effect dependency array. Silly goose. Let's add those:

1function Example() {
2 const [state, {set}] = useUndo('first')
3
4 React.useEffect(() => {
5 set('second')
6 }, [set])
7
8 React.useEffect(() => {
9 set('third')
10 }, [set])
11
12 return <pre>{JSON.stringify(state, null, 2)}</pre>
13}

Great, save, reload... Wait wut? Oh no! Here's what happened when I added those:

1{
2 "past": [
3 "first",
4 "second",
5 "third",
6 "second",
7 "third",
8 "second",
9 "third",
10 "second",
11 "third",
12 "second",
13 "third",
14 "second",
15 "third",
16 "second",
17 "third",
18 "second",
19 "third",
20 "second",
21 "third",
22 "second",
23 "third",
24 "second",
25 "third",
26 "second",
27 "third",
28 "second",
29 "third",
30 "... this goes on forever..."
31 ],
32 "present": "third",
33 "future": []
34}

But wait! Aren't we memoizing the set function? It shouldn't change unless its dependencies change... Wait... That includes the past and present values... And whoops! When we call set those values are changed, which leads to our infinite loop!

Now, this may seem contrived, but a similar bug could come up if you were updating the state based on network events and they came back in a different order from when they were sent out. Either way, you just don't want to have to think about this kind of thing right? Right.

So we can fix this problem with useReducer, but we can actually change our useState implementation and side-step this issue and I thought you'd enjoy that, so here it is:

1function useUndo(initialPresent) {
2 const [state, setState] = React.useState({
3 past: [],
4 present: initialPresent,
5 future: [],
6 })
7
8 const canUndo = state.past.length !== 0
9 const canRedo = state.future.length !== 0
10
11 const undo = React.useCallback(() => {
12 setState(currentState => {
13 const {past, present, future} = currentState
14
15 if (past.length === 0) return currentState
16
17 const previous = past[past.length - 1]
18 const newPast = past.slice(0, past.length - 1)
19
20 return {
21 past: newPast,
22 present: previous,
23 future: [present, ...future],
24 }
25 })
26 }, [])
27
28 const redo = React.useCallback(() => {
29 setState(currentState => {
30 const {past, present, future} = currentState
31
32 if (future.length === 0) return currentState
33
34 const next = future[0]
35 const newFuture = future.slice(1)
36
37 return {
38 past: [...past, present],
39 present: next,
40 future: newFuture,
41 }
42 })
43 }, [])
44
45 const set = React.useCallback(newPresent => {
46 setState(currentState => {
47 const {present, past} = currentState
48 if (newPresent === present) return currentState
49
50 return {
51 past: [...past, present],
52 present: newPresent,
53 future: [],
54 }
55 })
56 }, [])
57
58 const reset = React.useCallback(newPresent => {
59 setState(() => ({
60 past: [],
61 present: newPresent,
62 future: [],
63 }))
64 }, [])
65
66 return [state, {set, reset, undo, redo, canUndo, canRedo}]
67}

There are a few things I did to fix this issue:

  1. I used state updater callbacks in when calling the state updater functions so I could receive the currentState as an argument. This meant that I didn't need to list the state as a dependency.
  2. I combined all the state into a single object. I had to do this because there are situations where you need one value to determine another. For example, in redo, I need the value of present to update past and the value of future to update present.
  3. I did the calculations for canUndo and canRedo within the state updater callbacks based on the currentState I receive from the arguments so I didn't need to list those in the dependency array.

That solves the issue we were having and it does so pretty well, but let's try this same thing with useReducer to compare.

useReducer implementation

1const UNDO = 'UNDO'
2const REDO = 'REDO'
3const SET = 'SET'
4const RESET = 'RESET'
5
6function undoReducer(state, action) {
7 const {past, present, future} = state
8 const {type, newPresent} = action
9
10 switch (action.type) {
11 case UNDO: {
12 if (past.length === 0) return state
13
14 const previous = past[past.length - 1]
15 const newPast = past.slice(0, past.length - 1)
16
17 return {
18 past: newPast,
19 present: previous,
20 future: [present, ...future],
21 }
22 }
23
24 case REDO: {
25 if (future.length === 0) return state
26
27 const next = future[0]
28 const newFuture = future.slice(1)
29
30 return {
31 past: [...past, present],
32 present: next,
33 future: newFuture,
34 }
35 }
36
37 case SET: {
38 if (newPresent === present) return state
39
40 return {
41 past: [...past, present],
42 present: newPresent,
43 future: [],
44 }
45 }
46
47 case RESET: {
48 return {
49 past: [],
50 present: newPresent,
51 future: [],
52 }
53 }
54 }
55}
56
57function useUndo(initialPresent) {
58 const [state, dispatch] = React.useReducer(undoReducer, {
59 past: [],
60 present: initialPresent,
61 future: [],
62 })
63
64 const canUndo = state.past.length !== 0
65 const canRedo = state.future.length !== 0
66 const undo = React.useCallback(() => dispatch({type: UNDO}), [])
67 const redo = React.useCallback(() => dispatch({type: REDO}), [])
68 const set = React.useCallback(
69 newPresent => dispatch({type: SET, newPresent}),
70 [],
71 )
72 const reset = React.useCallback(
73 newPresent => dispatch({type: RESET, newPresent}),
74 [],
75 )
76
77 return [state, {set, reset, undo, redo, canUndo, canRedo}]
78}

Wow, the useUndo thing itself is actually very simple now. If we had started with useReducer from the get-go, we wouldn't have even considered adding anything to our dependency array because those functions are so simple they don't need anything. All the logic lives in our reducer. That helps us avoid the issue naturally.

You may find it interesting that the switch cases in our reducer are basically exactly what the contents of our functions were before we made the change.

When one element of your state relies on the value of another element of your state in order to update: useReducer

Conclusion

So if you want some "rules" (NOT ESLINT RULES), here they are:

  • When it's just an independent element of state you're managing: useState
  • When one element of your state relies on the value of another element of your state in order to update: useReducer

Outside of these "rules," everything else is really subjective. Honestly, even the "rules" are subjective because as I demonstrated, you can do everything you want with either one.

Also, please note that this applies on a case-by-case basis. You can absolutely use useState in the same component or hook that's using useReducer. And you can have multiple useStates and multiple useReducers in a single hook or component. That's no problem. Separate state logically by domain. If it changes together, it's likely better to keep together in a reducer. If something is pretty independent from other elements of state in that hook/component, then putting it with other elements of state is just adding unnecessary complexity and noise to that reducer and you'd be better off leaving it out on its own.

So it's not just "when I have more than X number of useStates I switch to useReducer." It's more nuanced than that. But hopefully this post helps you understand those nuances and reach for the tool that has the trade-offs that work best for your situation. In general, I suggest starting with useState, and moving to useReducer when you notice elements of state need to change together.

Good luck!

Discuss on TwitterEdit post on GitHub

Share article
Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. He's taught hundreds of thousands of people how to make the world a better place with quality software development tools and practices. He lives with his wife and four kids in Utah.

Join the Newsletter



Kent C. Dodds