Sean Groff

Sunglasses emoji
ghost emoji

Setting State based on Previous State in useEffect - Its a trap!

August 04, 2020

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 useEffects. I hone in on useEffects 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 or fooBar changes, the callback inside the useEffect 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 💙


Sean Groff

I am Sean, a Software Engineer in Kansas City passionate about React and JavaScript.