Setting State based on Previous State in useEffect - Its a trap!
Sean Groff
0 reactions 2020-08-04

The Bug Hunt 🐛 🏹
I recently had to fix a bug that made it to Production at work.
Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
The error itself is a little misleading. Our app doesn’t use either lifecycle method anywhere in the app.
After debugging the app in my local dev environment I was led to a file that had a handful of useEffect
s.
I hone in on useEffect
s whenever I’m chasing down infinite loops.
// actual code and logic removed for the sake of brevity
let [fooBar, setFooBar] = useState({ foo: null, bar: null })
useEffect(() => {
if (baz) {
setFooBar({ ...fooBar, bar: 123 })
}
}, [baz, fooBar])
Can you spot the infinite loop?
The Trap Explained 💣
When reading a useEffect
I recommend reading the dependency array first.
If the value of
baz
orfooBar
changes, the callback inside theuseEffect
will execute
Let’s focus on the setFooBar
function. We are updating the state with an object literal, spreading the previous state, and updating the bar
property value to 123
.
The previous state is fooBar
. In the useEffect
dependency array we see fooBar
listed as a dependency.
If the value of fooBar
changes, execute the useEffect
callback. Set fooBar
to {...fooBar, bar: 123}
. This changes the value of fooBar
so the useEffect
callback will execute again.
There is an infinite loop.
Solution 1 - Dispatch function update 🙋
The React useState
hook returns an array of two items. The current state
and a function that updates it.
Unbeknownst to many, this function accepts a callback function providing you access to the previous state
.
This “previous state” is guaranteed to be the latest state, unlike relying on a closure. Kent C. Dodds has a great article explaining the useState
dispatch function update here.
Let’s update our useEffect
code to utilize the function update instead.
useEffect(() => {
if (baz) {
setFooBar(prevState => ({ ...prevState, bar: 123 }))
}
}, [baz])
Now, the useEffect
no longer depends on the fooBar
value change to execute, solving our infinite loop issue!
Solution 2 - useReducer 🧙♂️
I don’t want useReducer
to hijack the main topic of this blog post but it is a viable solution.
let fooBarReducer = (state, action) => {
switch (action.type) {
'UPDATE_BAR':
return {...state, bar: action.bar}
default:
throw new Error('Invalid Action Type')
}
}
let [state, dispatch] = useReducer(fooBarReducer, { foo: null, bar: null })
useEffect(() => {
if (baz) {
dispatch({ type: 'UPDATE_BAR', bar: 123})
}
}, [baz, dispatch])
Our useEffect
has no dependency on the state
we are updating which also solves our infinite loop issue.
Conclusion 🧑🏫
A safe useState
rule to follow when updating state based on the previous state, is to always use the function update pattern.
Keep an eye out for this type of bug when reviewing code that contains a useEffect
.
Thanks for reading 💙