Prefer Function Updaters in State Setters
This one will be short and sweet. You’re using React. You have some state.
const [selectedItem, setSelectedItem] = React.useState(null)
You’re already on board with the concepts in useEncapsulation and Prefer Declarative State Updaters, so you make a custom hook combining your state with the functions that will update that state.
function useItemSelection() {
const [selectedItem, setSelectedItem] = React.useState(null)
const handlers = React.useMemo(
() => ({
selectItem: item => {
setSelectedItem(item)
},
unselectItem: () => {
setSelectedItem(null)
},
}),
[],
)
return [selectedItem, handlers]
}
Fantastic. Your selection logic is encapsulated in a nice, little custom hook. Your boss and colleagues love you and your code. All is well in the world!
Until…
You get some new requirements.
”We would love it if clicking on the currently selected item would unselect the item, rather than do nothing. Can you do that?”
Of course we can! But the question is how shall we do it? There are at minimum, two ways. Let’s do it the less-than-great way first.
In order to “unselect” an item, we need to know that the item we’re selecting is the same as current selectedItem
. We could use the selectedItem
that’s in scope.
function useItemSelection() {
const [selectedItem, setSelectedItem] = React.useState(null)
const handlers = React.useMemo(
() => ({
// ...same handlers as before
smartSelectItem: item => {
// Here we use `selectedItem` from the parent scope
// We're _also_ assuming the "items" have an `id`
// We will fix that later
if (item?.id === selectedItem?.id) {
setSelectedItem(null)
return
}
setSelectedItem(item)
},
}),
// Now we had to add `selectedItem` as a dependency
// All handlers will update _every_ time `selectedItem` updates
[selectedItem],
)
return [selectedItem, handlers]
}
We can pop this into a component and see that it works.
Great, this works as expected. Why is it less-than-great?
Because we’re relying on a dependency we don’t need to rely on, and in so doing, we’re forcing our state updaters to change unnecessarily.
If we use a “function updater” instead of relying on the selectedItem
in scope, we can avoid the selectedItem
dependency entirely.
const handlers = React.useMemo(() => {
// ...same handlers as before, except...
smartSelectItem: item => {
setSelectedItem(currentItem => {
if (item?.id === currentItem?.id) return null
return item
})
}
}, [])
Now we no longer have a dependency because React is giving us all the information we need to determine the next state within the state setter itself. On top of that, we get a tidy little, almost pattern matching like function body. The fact that each branch of logic must return the nextState
helps us in other ways. I’ve made the following bug a few times in my career:
{
smartSelectItem: item => {
if (item?.id === selectedItem?.id) {
setSelectedItem(null)
}
setSelectedItem(item)
}
}
See my mistake?
I forgot to return
in the guard statement. Classic.
Other Benefits
Hooks solved a lot of problems, but they didn’t get rid of a few “gotchas”. In fact, useState
created an extra gotcha you didn’t have to deal with in class component days. Let’s start there:
Object updates are not merged anymore
In the class component days, you could create a state like this:
this.state = {
error: null,
data: null,
loading: false,
}
And you could do updates like this:
this.setState({ loading: true })
And your state
would be:
console.log(this.state) // { error: null, data: null, loading: true }
But try this with a hook instead:
const [state, setState] = React.useState({
error: null,
data: null,
loading: false,
})
setState({ loading: true })
console.log(state) // { loading: true }
What?! That’s right, objects aren’t merged. They are replaced.
I personally think this makes sense. By making the state setter more rudimentary, the API becomes straightforward. No longer do you need to know about the “behavior” of the state setter. It’s less information you need to store in memory, and I’m a big fan of that.
You can get around this using function updaters:
const [state, setState] = React.useState({
error: null,
data: null,
loading: false,
})
setState(currentState => ({
...currentState,
loading: true,
}))
console.log(state) // { error: null, data: null, loading true }
Calling state setters more than once in a handler body
This “gotcha” has also been true since the class component days. If you call a state setter more than once in the same function body, there’s a really good chance that the update will not be what you expect when that function body completes.
Here’s an absurd example, with a useTripleCounter
hook:
function useTripleCount() {
const [state, setState] = React.useState(0)
const handlers = React.useMemo(
() => ({
inc: () => {
setState(state + 1)
setState(state + 1)
setState(state + 1)
},
dec: () => {
setState(state - 1)
setState(state - 1)
setState(state - 1)
},
}),
[state],
)
return [state, handlers]
}
If I use this in a component, what’s going to happen? All three setState
s are going to be called, utilizing the same state
dependency and state
will only change by a value of 1
. Not to mention, our handlers
update every single time state
updates, which may lead to more rerenders than necessary.
But, if we swap those updates for function updaters:
const handlers = React.useMemo(
() => ({
inc: () => {
// often I shorten `state` to just `s` when the update is this small
setState(s => s + 1)
setState(s => s + 1)
setState(s => s + 1)
},
dec: () => {
setState(s => s - 1)
setState(s => s - 1)
setState(s => s - 1)
},
}),
[],
)
What happens now? Now our updates are all applied. Our count increments and decrements by 3
. I hope you can see how this might be useful in a real situation.
Summary
Using function updaters leads to fewer gotchas and requires fewer dependencies than simply replacing the current state. Consider using them more often to improve your stateful React components.
Bonus: Improving the useItemSelection
hook
I wanted to take a moment and make this hook a little better before finishing the post. This relates to my recent post on dependency injection which you should read.
Did you happen to notice another dependency in the useItemSelection
hook? Here it is in full so you can look for it:
function useItemSelection() {
const [selectedItem, setSelectedItem] = React.useState(null)
const handlers = React.useMemo(
() => ({
selectItem: item => {
setSelectedItem(item)
},
unselectItem: () => {
setSelectedItem(null)
},
smartSelectItem: item => {
setSelectedItem(currentItem => {
if (item?.id === currentItem?.id) return null
return item
})
},
}),
[],
)
return [selectedItem, handlers]
}
Hopefully you recognized that smartSelectItem
knows a bit too much about the shape of the items being selected. What if these items are not objects? What if they are but don’t have id
s, but some other key?
Let’s use dependency injection and a default parameter to fix this:
const tryId = x => x?.id
function useItemSelection(getKey = tryId) {
const [selectedItem, setSelectedItem] = React.useState(null)
const handlers = React.useMemo(
() => ({
selectItem: item => {
setSelectedItem(item)
},
unselectItem: () => {
setSelectedItem(null)
},
smartSelectItem: item => {
setSelectedItem(currentItem => {
if (getKey(item) === getKey(currentItem)) return null
return item
})
},
}),
[getKey],
)
return [selectedItem, handlers]
}
Now useItemSelection
is a bit more flexible. Perhaps you want to use name
as a key instead:
const [state, handlers] = useItemSelection(x => x?.name)
No longer does useItemSelection
force your data into a shape. You can easily conform useItemSelection
to your data.