DEV Community

Cover image for React: Setting State On An Unmounted Component is probably not a problem
Brad Westfall
Brad Westfall

Posted on • Edited on

React: Setting State On An Unmounted Component is probably not a problem

(Originally posted at ReactTraining.com)

Did you ever get this warning in React?

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application.

Did you know you can probably ignore it if you do get it? ๐Ÿค”

This warning has caused lots of bad habits and unnecessary fixes in React among developers who don't fully understand it. That's probably why React got rid of the message in React 18, because we usually do not have a memory leak when we get this message so there's nothing to be fixed.

For years, React devs (including myself) were jumping through hoops to get this message to go away. React must have some secret insights to whether we have memory leaks right? And if I can only get this warning to disappear, I must have fixed the problem? Right?

Let's start by showing you when you would have received this error prior to React 18.

First, we need to create some sort of delay for setting state so it runs after the component has unmounted. A common use-case where developers would see this warning is when we set state from an async operation like data fetching in useEffect():

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

  useEffect(() => {
    // Effect runs and promise starts
    getUser(userId)
      .then((user) => {
        // The promise resolves later and there's no guarantee
        // the component is still mounted, yet we're setting state
        setUser(user)
      })
      .catch((err) => {
        setError(err)
      })
  }, [userId])

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

If you never saw the warning in React 17, it might be because you were developing locally and your network latency was near 0 to your local environment. This is the kind of thing you can miss in local development but makes its way into production with real network latency.

Remember, promises are called promises for a reason. They will resolve or reject. That's the promise they make. It doesn't matter to JavaScript that your component is considered "not mounted".

So what happens when we try to set state on an unmounted component?

Nothing.

React considers this a no operation and they just ignore it. There is absolutely no memory leak in this code.

So why the dramatic warning?

From React's perspective they have no idea what you're doing in your useEffect. They call this function and expect that you might return a cleanup to fix any of your issues that they don't know about:

useEffect(() => {
  // ๐Ÿ™ˆ React can't see any of this
}, [])
Enter fullscreen mode Exit fullscreen mode

There's no way they could know if you did create a memory leak. They just know that setting state on an unmounted component has a very small chance of being a leak so they're just letting you know. Promises like the one we were doing don't generally cause memory leaks. But again, React didn't even know we did a promise.

Don't worry, I'll show you how to identify a memory leak further down.

Cleanup Function

As many of you already know, useEffect has a cleanup function that can generally be used to cleanup any problems you may have created with your side effect. For these next sections, it's very important that you understand the two circumstances that it's called.

I notice a lot of React devs are aware that the cleanup gets called when the component unmounts. But it's critical to understand it also gets called when we switch effects. Please understand exactly how that works from my other article before continuing.

If you understand cleanups, then you understand this code fixes a few problems. Aside from preventing us from setting state on the unmounted component, it also fixes a race condition when the userId changes fast:

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

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

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

To be very clear:

  1. We don't have a memory leak so I don't care about the fact that this cleanup prevents us from setting state on an unmounted component.
  2. I do care about the other race conditions this fixes so this is the real reason for needing to cleanup in this case.

As I stated before, promises don't generally create memory leaks.

What will create a memory leak?

Subscriptions that we don't unsubscribe from.

Let's do a stock quote component and imagine we're subscribing to a socket on the server that's pushing realtime data to us:

function StockQuote({ ticker }) {
  const [price, setPrice] = useState(null)

  useEffect(() => {
    subscribeToTicker(ticker, (newPrice) => {
      setPrice(newPrice)
    })
  }, [ticker])

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

This component subscribes but it never unsubscribes. If we're looking at the Google stock price and we navigate away and therefore unmount, we create a memory leak by the fact that we're going to be setting state forever on a component that's no longer mounted. We might go back to this component and it doesn't matter if we go back to Google ticker again, we will now have two subscriptions that stick around forever.

See what I mean about promises now? Promises only resolve or reject once so they don't create the ongoing problems that subscriptions are prone to.

Aside from unmounting, there's another memory leak that will probably occur. If we switch from Google to Apple and get a re-render, the ticker change will run the effect again and now now we're subscribed to Apple and Google at the same time. Memory leak created.

Let's do a cleanup to unsubscribe and fix the leaks:

useEffect(() => {
  subscribeToTicker(ticker, (newPrice) => {
    setPrice(newPrice)
  })
  return () => unsubscribe(ticker)
}, [ticker])
Enter fullscreen mode Exit fullscreen mode

You'll notice a pattern where the isCurrent strategy we did works for promises because again they just resolve or reject once, but subscriptions need to be "unsubscribed".

This also fixes both of those memory leak scenarios because remember the cleanup gets called in two circumstances:

  1. When we unmount we unsubscribe. Memory leak fixed
  2. When we switch from Google to Apple, this cleanup will unsubscribe from Google before it subscribes to Apple. Memory leak fixed.

What are common subscriptions?

This is not a comprehensive list, but here are a few things that would be side effect subscriptions that need to be unsubscribed from in the cleanup:

  1. Sockets with the server.
  2. setInterval(). This is a subscription to the clock which needs to be cleaned up with clearInterval().
  3. DOM Event listeners. Sometimes you might need to do window.addEventListener() from a useEffect().

Event Based Side Effects

Side effects are generally either:

  • Event Based Side Effect - A side effect that runs because a DOM event occurred, like a button click to update a user.
  • Render Phase Based Side Effect - A side effect that runs after render phases. These are the ones that use useEffect(), like data-fetching when the component mounts.

Some people (including me) used to think that we should run all side effects out of a useEffect function and avoid running them inside your events. This was a common opinion for a while but let's debunk it.

function UserList() {
  const [message, setMessage] = useState('')

  // Event
  onRemoveUser(userId) {
    removeUser(userId).then(() => {
      setMessage(`User ${userId} was removed`)
    })
  }

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

The thought was, if we run our side effect strait out of the function, then how are we going to clean it up?

The real question is, why do we need to clean it up? Are we trying to fix the "setting state on unmounted components" problem? I'll say it again, that was never a problem it was just a warning that you might have a memory leak and we don't have one here with this promise.

And yet, people would jump through hoops to get the message to go away like we might set state from the event function so we could respond to the re-render and new state with a useEffect() just so we could have a cleanup just so we could fix a non-existent problem.

Fine, but what if the user rapidly clicked to remove users like our race condition example from this article? Can we create a race condition problem where we need a cleanup and therefore need to jump through hoops to get a useEffect() to run instead of running out of the event?

I don't think so. The problematic race condition we did in that article was more about loading and showing the right data to the user. It was a real problem. Here, the only problem that would happen if the user clicks to remove a bunch of users quickly is we'd see the "success message" change frequently. That's a user-experience issue that we can fix by doing success messages differently, it's not a race condition bug like we had talked about.

Summary

The big takeaway is that we only need to worry about actual memory leaks and that setting state on an unmounted component simply tells React that there's a slight possibility we have a problem. I say slight because it's just my opinion that of all the ways to delay the setting of state, you're probably doing promises more than subscriptions.

I feel like removing the warning was the best decision, but here's what's going to happen next:

  1. You'll have a memory leak you are unaware of. Just be keen on understanding that subscriptions are prone to them and you'll probably be okay.
  2. Some devs will interpret the words from that GitHub pull request as meaning we don't need to cleanup. Their only notion of cleaning up a promise-based side effect was to do isMounted to prevent the setting of state on the unmounted component. They might think they can remove that logic now because there is no memory leak and no warning. But as we saw already in the other article I wrote, the logic fixes other problems.

Top comments (1)

Collapse
 
brense profile image
Rense Bakker

True it won't create a memory leak, but it can cause unexpected behavior when you make an API call, the component state changes triggering another API call without cancelling the first. Now you're in a situation where you don't know which call is going to finish first and the results of the 2nd call (which is newer) could be overwritten by the first call, because it took longer to complete for some reason.

But yes, in general we don't have to care about the delayed state update on an unmounted component.