Note:
Some examples are interactive on my blog, so you might have a better experience reading it there:
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:
const [count, setCount] = React.useState(0)
// 🚨 depends on the current count value to calculate the next value
<button onClick={() => setCount(count + 1)}>Increment</button>
// ✅ uses previousCount to calculate next value
<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
Example:
function App() {
const [count, setCount] = React.useState(0)
return (
<button
onClick={() => {
setCount(count + 1)
setCount(count + 1)
}}
>
🚨 This will not work as expected, count is: {count}
</button>
)
}
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:
function App() {
const [count, setCount] = React.useState(0)
return (
<button
onClick={() => {
setCount((previousCount) => previousCount + 1)
setCount((previousCount) => previousCount + 1)
}}
>
✅ Increment by 2, count is: {count}
</button>
)
}
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:
function Counter({ incrementBy = 1 }) {
const [count, setCount] = React.useState(0)
// 🚨 will create a new function whenever count changes because we closure over it
const increment = React.useCallback(() => setCount(count + incrementBy), [
incrementBy,
count,
])
// ✅ avoids this problem by not using count at all
const increment = React.useCallback(
() => setCount((previousCount) => previousCount + incrementBy),
[incrementBy]
)
}
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:
const [value, setValue] = React.useState(true)
// 🚨 toggle with useState
<button onClick={() => setValue(perviousValue => !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 useReducer
const [value, toggleValue] = React.useReducer(previousValue => !previousValue, true)
<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):
// 🚨 will unnecessarily be computed on every render
const [value, setValue] = React.useState(calculateExpensiveInitialValue(props))
// ✅ looks like a small difference, but the function is only called once
const [value, setValue] = React.useState(() => calculateExpensiveInitialValue(props))
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 example:
function App() {
const [name, setName] = React.useState('Elias')
// 🤯 clicking this button will not re-render the component
return (
<button onClick={() => setName('Elias')}>
Name is: {name}, Date is: {new Date().getTime()}
</button>
)
}
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:
// 🚨 age will be inferred to `undefined` which is kinda useless
const [age, setAge] = React.useState(undefined)
// 🆗 but a bit lengthy
const [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:
// ✅ age will be `number | undefined`
const [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 ⬇️
Top comments (5)
Did not know about the lazy initializer. Will definitely be using that more in the future
This is an antipattern anyway. Better to useMemo with useEffect in case of expensive calculations
Not sure what you mean with that. Do you have an example maybe?
narrator: … he did not have an example …
Learn a lot !!
Really good articles !!!