Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
SeanGroff opened this issue Nov 14, 2022 · 0 comments
Open
Labels
Published Publish a Blog post

Comments

@SeanGroff
Copy link
Owner

SeanGroff commented Nov 14, 2022


date: '2020-08-04'
category: react
image: /assets/venus-fly-trap.jpg
tags: [react, hooks, useeffect, javascript, js]
slug: set-state-trap
featured: yes

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 💙

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Published Publish a Blog post
Projects
None yet
Development

No branches or pull requests

1 participant