Skip to content
TkDodo's blog
TwitterGithub

Things to know about useState

React, useState pitfalls, JavaScript, TypeScript3 min read

use state pitfalls
  • 한국어
  • Add translation

React.useState is pretty straightforward to use. A value, a setter function, an initial state. What hidden gems could possibly be there to know about? Well, here are 5 things you can profit from on a daily basis that you might not have known:

1: The functional updater

Good old setState (in React class components) had it, and useState has it, too: The functional updater! Instead of passing a new value to the setter that we get from useState, we can also pass a function to it. React will call that function and gives us the previousValue, so that we can calculate a new result depending on it:

functional-updater
1const [count, setCount] = React.useState(0)
2
3// 🚨 depends on the current count value to calculate the next value
4<button onClick={() => setCount(count + 1)}>Increment</button>
5
6// ✅ uses previousCount to calculate next value
7<button onClick={() => setCount(previousCount => previousCount + 1)}>Increment</button>

This might be totally irrelevant, but it might also introduce subtle bugs in some situations:

Calling the same setter multiple times

Live example:

Each click will only increment the count once, because both calls to setCount closure over the same value (count). It's important to know that setCount will not immediately set the count. The useState updater only schedules an update. It basically tells React:

Please set this value to the new value, somewhen.

And in our example, we are telling React the same thing twice:

Please set the count to two
Please set the count to two

React does so, but this is probably not what we intended to say. We wanted to express:

Please increment the current value
Please increment the current value (again)

The functional updater form ensures this:

When async actions are involved

Kent C. Dodds has written a lengthy post about this here, and the conclusion is:

Any time I need to compute new state based on previous state, I use a function update.

— Kent C. Dodds

I can second that conclusion and encourage you to read that article thoroughly.

Bonus: Avoiding dependencies

The functional updater form can also help you to avoid dependencies for useEffect, useMemo or useCallback. Suppose you want to pass an increment function to a memoized child component. We can make sure the function doesn't change too often with useCallback, but if we closure over count, we will still create a new reference whenever count changes. The functional updater avoids this problem altogether:

avoiding-dependencies
1function Counter({ incrementBy = 1 }) {
2 const [count, setCount] = React.useState(0)
3
4 // 🚨 will create a new function whenever count changes because we closure over it
5 const increment = React.useCallback(() => setCount(count + incrementBy), [
6 incrementBy,
7 count,
8 ])
9
10 // ✅ avoids this problem by not using count at all
11 const increment = React.useCallback(
12 () => setCount((previousCount) => previousCount + incrementBy),
13 [incrementBy]
14 )
15}

Bonus2: Toggling state with useReducer

Toggling a Boolean state value is likely something that you've done once or twice before. Judging by the above rule, it becomes a bit boilerplate-y:

🚨-toggle-with-use-state
1const [value, setValue] = React.useState(true)
2
3<button onClick={() => setValue(previousValue => !previousValue)}>Toggle</button>

If the only thing you want to do is toggle the state value, maybe even multiple times in one component, useReducer might be the better choice, as it:

  • shifts the toggling logic from the setter invocation to the hook call
  • allows you to name your toggle function, as it's not just a setter
  • reduces repetitive boilerplate if you use the toggle function more than once
✅-toggle-with-use-reducer
1const [value, toggleValue] = React.useReducer(previous => !previous, true)
2
3<button onClick={toggleValue}>Toggle</button>

I think this shows quite well that reducers are not only good for handling "complex" state, and you don't need to dispatch events with it at all costs.

2: The lazy initializer

When we pass an initial value to useState, the initial variable is always created, but React will only use it for the first render. This is totally irrelevant for most use cases, e.g. when you pass a string as initial value. In rare cases, we have to do a complex calculation to initialize our state. For these situations, we can pass a function as initial value to useState. React will only invoke this function when it really needs the result (= when the component mounts):

lazy-initializer
1// 🚨 will unnecessarily be computed on every render
2const [value, setValue] = React.useState(
3 calculateExpensiveInitialValue(props)
4)
5
6// ✅ looks like a small difference, but the function is only called once
7const [value, setValue] = React.useState(() =>
8 calculateExpensiveInitialValue(props)
9)

3: The update bailout

When you call the updater function, React will not always re-render your component. It will bail out of rendering if you try to update to the same value that your state is currently holding. React uses Object.is to determine if the values are different. See for yourself in this live example:

4: The convenience overload

This one is for all TypeScript users out there. Type inference for useState usually works great, but if you want to initialize your value with undefined or null, you need to explicitly specify the generic parameter, because otherwise, TypeScript will not have enough information:

use-state-type-inferrence
1// 🚨 age will be inferred to `undefined` which is kinda useless
2const [age, setAge] = React.useState(undefined)
3
4// 🆗 but a bit lengthy
5const [age, setAge] = React.useState<number | null>(null)

Luckily, there is a convenience overload of useState that will add undefined to our passed type if we completely omit the initial value. It will also be undefined at runtime, because not passing a parameter at all is equivalent to passing undefined explicitly:

use-state-convenience-overload
1// ✅ age will be `number | undefined`
2const [age, setAge] = React.useState<number>()

Of course, if you absolutely have to initialize with null, you need the lengthy version.

5: The implementation detail

useState is (kinda) implemented with useReducer under the hood. You can see this in the source code here. There is also a great article by Kent C. Dodds on how to implement useState with useReducer.

Conclusion

The first 3 of those 5 things are actually mentioned directly in the Hooks API Reference of the official React docs I linked to at the very beginning 😉. If you didn't know about these things before - now you do!


How many of these points did you know? Leave a comment below. ⬇️