React 17 runs useEffect cleanup functions asynchronously


Effect cleanup functions

React performs the cleanup when the component unmounts. The useEffect hook is built in a way that if we return a function within the method, it gets executed when the component unmounts.

useEffect(() => {
  // This is the effect itself.
  return () => {
    // This is its cleanup.
  };
});

Until React 17, the useEffect cleanup mechanism used to run during commit phase. This implies that when a component is unmounting, React would execute the cleanup functions and then update the screen. It is similar to the behavior of componentWillUnmount in classes.

This is not ideal for larger apps because it slows down large screen transitions (e.g., switching tabs).

In React 17, the useEffect cleanup functions are delayed till the commit phase is completed. In other words, the useEffect cleanup functions run asynchronously - for example, if the component is unmounting, the cleanup runs after the screen has been updated. Additionally, React 17 will always execute all effect cleanup functions (for all components) before it runs any new effects.

Let’s check out a simple example of showing and hiding the fetched users. When we click on the Show users button, it fetches the users through API and displays the user list. On clicking on Hide users, the UserInfo component unmounts.

We will be using the Profiler API to measure how the React application renders.

Let’s go through some terms used in Profiler API.

phase(“mount” or “update”): - Identifies whether the component mounted for the first time or is updated.

commitTime(number): - Timestamp when React committed the current update.

//App.jsx

export default function App() {

  const callback = (phase, actualTime, baseTime, commitTime) => {
    console.group(phase);
    console.table({
      commitTime,
    });
    console.groupEnd();
   }

  return (
    <Profiler id="users" onRender={callback}>
      <div className="App">
        <section>
          <h2>Users:</h2>
          <Button title="Users">
            <Users />
          </Button>
        </section>
      </div>
    </Profiler>
  );
}
//Button.jsx

export default function Button({ title, children }) {
  let [toggle, setToggle] = useState(false);
  return (
    <section>
      <button className="primary" onClick={() => setToggle(!toggle)}>
        {toggle ? `HIDE ${title}` : `SHOW ${title}`}
      </button>
      {toggle && children}
    </section>
  );
}
//Users.jsx

export default function UserInfo() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const abortController = new AbortController();
    const signal = abortController.signal;
    fetch(USERS_API, { signal: signal })
      .then(results => results.json())
      .then(data => {
        setUser(data);
      });
    return function cleanup() {
      console.log('I am in cleanup function');
      abortController.abort();
    };
  }, []);

  return (
    <div>
      <h4>Users</h4>
      {user === null ? (
        <p>Loading User Data ...</p>
      ) : (
        <pre>{JSON.stringify(user, null, 4)}</pre>
      )}
    </div>
  );
}

Before

Before the changes in React 17, we can see that the cleanup function gets executed and, then the screen gets updated which thereby increases the commit time.

After

With the changes in React 17, the cleanup function run asynchronously, after the screen gets updated which decreases the commit time.

This change in behavior for cleanup functions has accidentally fixed some issues. One of the issues related to the focus event has been fixed.

Note:

We don’t need to worry about fixing the setState warning on the unmounted component - Warning: Can't call setState (or forceUpdate) on an unmounted component.

React specifically checks for this case and does not fire setState warnings in the short gap between unmounting and the cleanup. So code canceling requests or intervals can almost always stay the same.

Check out the pull request to learn more.