The useEffect cleanup and the two circumstances it's called.

bradwestfall

Brad Westfall

Posted on March 23, 2023

The useEffect cleanup and the two circumstances it's called.

(Originally posted at ReactTraining.com)

The cleanup function is a function returned from within the effect function. It gets called when the component unmounts but you probably already knew that.

In my workshops I ask developers when the function gets called and I regularly get this one answer. But in the 100s of workshops I've taught over the years, I think I've only ever heard the full correct answer from one person (hats off to that person).

useEffect(() => {
  getUser(userId).then((user) => {
    setUser(user)
  })

  // Cleanup Function: Called when we unmount
  return () => {}
}, [userId])
Enter fullscreen mode Exit fullscreen mode

You're probably skimming this article and want to jump strait to the second circumstance it gets called:

The cleanup also gets called when the dependency array changes and the effect needs to run again. But it's the previous effect's cleanup that runs before the next effect function runs. You might need the full article to really understand...

Why Cleanup

To better understand both of the circumstances it's called, we need to do some examples of why you would want to do a cleanup in the first place.

In the code above, the most well-known reason is unfortunately the most misguided one so we'll start there. People think we need to cleanup because if we don't, we might "set state on an unmounted component".

We have another post where we do a deep dive into why you shouldn't care about fixing this particular problem, but it is a great starting point because we need this same fix for other reasons we'll show later.

Preventing our component from setting state when it's unmounted can look like this:

useEffect(() => {
  // 1. After the component renders, the useEffect function is called
  // and we're guaranteed to be mounted at this point so set this flag
  let isMounted = true
  getUser(userId).then((user) => {
    if (mounted) {
      setUser(user)
    }
  })

  // 2. We do the actual side effect (getUser) and now we need to return
  // a way of "cleaning up" any problems that might occur because of it.
  return () => {
    isMounted = false
  }
}, [userId])
Enter fullscreen mode Exit fullscreen mode

The major takeaway so far is that we're returning the cleanup but not calling it, and when we return this cleanup, the promise is still pending.

Now, the race condition begins. Is the component going to unmount first (maybe we navigated to another page) before the promise resolves? Or is the promise going to resolve first?

If the component unmounts first before the promise resolves, this code will prevent us from setting state on an unmounted component -- if that's what you wanted to do. I'm saying that doesn't matter

To be clear, I do want the cleanup solution we wrote but only because it fixes a different problem, a race condition.

Race conditions

Let's go back to before we had our isMounted code:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null)

  useEffect(() => {
    getUser(userId).then((user) => {
      setUser(user)
    })
    return () => {}
  }, [userId])

  return <div>...</div>
}
Enter fullscreen mode Exit fullscreen mode

In this UserProfile component, let's imagine we can click on friends of this user. Perhaps we're looking at users/1 now but we could quickly click and go to users/2, then users/3, then users/4, and finally users/5. All these clicks would cause our component to re-render with a new userId prop.

We make these several clicks very fast and ultimately users/5 should be the one we end up seeing since it was the last one clicked. Here's what happens though...

Each time we click, the re-render and new userId means we re-run the effect function based on that new userId. We now have a race-condition where the network requests could return in a different order than we sent them. The network request that resolves last wins. We want to be looking at users/5 but perhaps the network request for users/4 was a lot slower than the rest and it resolves last. You're now incorrectly looking at User 4.

The other circumstance the cleanup gets called...

The cleanup function will get called when we "switch" effects. In other words when the dependency array changes and we're about to run the useEffect() function again. React will run the previous effect's cleanup just before we run a new effect. We can illustrate this with a timeline.

Timeline...

Conceptually, this is what it's like when React calls our functional component, let's think of the effect that runs as being the "current effect":

UserProfile() // useEffect runs based on user 1: this is the current effect
Enter fullscreen mode Exit fullscreen mode

Lets say we get some re-renders that have nothing to do with the userId changing. React calls our component again but the current effect still belongs to that first render when it ran:

UserProfile() // <-- current effect
UserProfile() // re-render
UserProfile() // re-render
Enter fullscreen mode Exit fullscreen mode

So you can see that we might have a "recent render", but the "current effect" was from a previous render.

Then the userId changes and we get a re-render again. This re-render needs to run the useEffect() again. But wait, before we do that we need to "cleanup" the old "current effect" first.

UserProfile() // 1. cleanup this effect first
UserProfile()
UserProfile()
UserProfile() // 2. run this effect after the previous cleanup runs (now current)
Enter fullscreen mode Exit fullscreen mode

If we do a cleanup like this, we can fix our race condition:

useEffect(() => {
  let isCurrent = true
  getUser(userId).then((user) => {
    if (isCurrent) {
      setUser(user)
    }
  })
  return () => {
    isCurrent = false
  }
}, [userId])
Enter fullscreen mode Exit fullscreen mode

Hey look, it's the exact same solution as the one we used to not set state on an unmounted component only the variable is more appropriately named because we now understand that the function isn't only called when we unmount.

With this cleanup, every time the userId changes, we cleanup the previous effect first which prevents it from setting state when it resolves. Then we run the effect for the next user we want to see and it now becomes the current effect.

When this happens really fast and we change the current effect while we still have pending promises, the net result is we avoid the race condition by "canceling" the previous effects ability to set state. We want to only see users/5 when it resolves and all previous effects will not be able to set state regardless of when they resolve.

It's interesting though that this solution also prevents us from setting state on an unmounted component -- not that we care about that, but it's interesting.

In a way, you could conceptualize these two circumstances as being combined into one rule:

We cleanup when an effect is no longer relevant. It's not relevant when we unmount and when we need to ditch an old effect to start a new one. However you think of it, as long as you understand it I'm okay with that.

Happy coding.

💖 💪 🙅 🚩
bradwestfall
Brad Westfall

Posted on March 23, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related