React Today I Learned

It has been an interesting journey with React hooks. In my most recent project, I adopted them wholesale from the beginning. I quickly saw how concise and effective they were! I was pleased that the React team made such a bold change which, in my opinion, has led to much cleaner and more concise React code than what came before, despite being drastically different.

That said, over the past few months I’ve taken hooks to the limit and have hit some of the edge cases that have required me to correct my understanding several times.

Specifically, I realized I was writing useEffect slightly incorrectly.

It had to do with two things:

  1. An overly-simplistic mental model for how it was working
  2. Not listing all of the dependencies

The problem had started manifesting in difficult-to-identify edge cases involving timers and stale values (truly the bane of any frontend dev’s existance). But luckily, there was a quick fix:

TL;DR - Install eslint-plugin-react-hooks and adapt your code to work with the recommended defaults.

How could it be that easy? Well, it turns out the React team spent a ton of time making sure these linter rules are correct for most cases.

In the rest of the post, I’ll explain how I got it wrong and why running the linter fixes things.

Fixing the Mental Model

When I started using useEffect I just thought it was the “new way” to write componentDidMount, componentDidUpdate, and componentWillUnmount. I was happy that they combined these three lifecycle methods into one easy-to-write hook. But this understanding was flawed.

And it was unfortunately due to this section of the React hooks docs:

Tip

If you’re familiar with React class lifecycle methods, you can think of useEffect Hook as componentDidMount, componentDidUpdate, and componentWillUnmount combined.

🙃

Before I go any further, I want to cite this blog post by Dan Abramov as instrumental in clearing up the big misconceptions I had.

Here’s the gist of my current understanding: don’t think of useEffect as a lifecycle method. That’s the old React. Instead, think of it as a tool for invoking side effects after rendering has happened.

Facts about useEffect:

  1. It always happens after a render.
  2. It always runs asynchronously (as you might expect since the first arg is a callback).
  3. You can control which rerenders cause an effect via the dependencies array

We’ll take about #3 in the next section, but basically, I believe the correct mental model for the useEffect hook is something like:

“Hey React, at some point after you’ve finished rendering, can you run this callback?”


Additionally, the return value of useEffect is often referred to as a “cleanup function” and will re-run after the next re-render but before everything else in the effect hook. It’s something like:

“Hey React, the next time you’re about to run my effect callback, run this cleanup function first.”

With an empty dependencies array, it effectively runs the cleanup callback on unmount.

With dependencies present, it runs every subsequent rerender when the dependencies have changed, before the next effect runs.


Check out this codepen, and keep the console open the whole time. It should help to explain when both the main effect callback and the cleanup function get called. Here is a diagram of what’s going on that should make it clearer hopefully:

Use Effect Diagram

Now, with a better mental model, we can finally talk about the dependencies array.

The Dependencies Array

Let’s now examine the three possible useEffect configurations:

1. Run after every render with no dependencies specified

useEffect(() => {
  console.log("re-rendered");
});

This is the most basic use case, but it seems less commonly-used than the other ways. It will run its side effect after every single render of the component. In this case the side effect is writing to the console. In practice, you might do other things like:

  • write to the document title
  • update a ref
  • focus an element

2. Run after the first render only with empty dependency array

A common example of running useEffect just once looks like this:

useEffect(() => {
  const data = await fetchTheData();
  setSomeState(data);
}, []);

This invokes a side effect the first time the component is rendered. Because there is an empty array as the second argument, as long as this component remains mounted it will not run this effect again after the first render.

But this example is also wrong, unfortunately, a mistake I learned the hard way 😬. The problem is that it actually does have dependencies that are not listed. The array of dependencies is empty and yet the callback references (via closure) a function called fetchTheData. The corrected example should look like this:

useEffect(() => {
  const data = await fetchTheData();
  setSomeState(data);
}, [fetchTheData]);

This reflects the fact that functions themselves are not static and can change, in which case should trigger the effect again.

A good follow-up question to this is, why didn’t we also add setSomeState to the watched depdendencies list? From the React docs:

Note

React guarantees that setState function identity is stable and won’t change on re-renders. This is why it’s safe to omit from the useEffect or useCallback dependency list.

Having an empty dependencies array is less common than I initially assumed. One instance where I still do use it, however, is to replicate behavior similar to componentWillUnmount. That is, I will create an empty dependencies array and specify something to be cleaned up when the component unmounts, e.g. a timer.

useEffect(() => {
  return () => {
    window.clearInterval(timerId.current);
  };
}, []);

In the above example, timerId is a stable ref and does not need to be watched. And we only want the timer to clear itself if the component unmounts.

3. Run after dependencies change by listing all dependencies

In practice, I think most calls to useEffect will be in response to explicitly-listed dependencies changing. The following example code might show a modal prompting a user to sign in after 10 seconds:

const [isLoggedIn, setIsLoggedIn] = useState(false);
const [didShowModal, setDidShowModal] = useState(false);

useEffect(() => {
  if (!isLoggedIn && !didShowModal) {
    timerId.current = window.setTimeout(() => {
      setDidShowModal(true);
    }, 10_000);
  }
  return () => {
    window.clearTimeout(timerId.current);
  };
}, [isLoggedIn, didShowModal]);

In this example, it might now seem counter-intuitive to have a clearTimeout cleanup function. But if somehow isLoggedIn or didShowModal get flipped to true, then we actually do want to clear the timeout. Not just when the component unmounts.

The dependencies list above will actually be automatically flagged by the previously-mentioned exhaustive dependencies ESLint rule. This is great because I don’t really have to ask myself the question “should I really be ‘watching’ this?” — the linter has been painstakingly programmed to be correct in most use cases.

Conclusion

Why was this confusing in the first place?

I think the problem people have with the Effect hook comes down to trying to replicate component lifecycle method behavior. But it’s extra confusing because unlike class-based components, which references to this.props and this.state — i.e. a constant this that never changed, regardless of whether rendering was happening or not, the new hook-based React kind of turns that on its head. Everything in a function component is the render method so to speak, and hooks are just declarative stable references. In other words, there is no stable this anymore, so we have to opt-in to keeping track of things across renders using the hooks.

According to Dan Abramov’s blog post, each render has its own everything. So you have to reflect this in the useEffect dependencies array by telling it to explicitly keep track of / subscribe to things that will change. And if you want things to persist across renders, you should explicitly opt-in to that behavior with useRef — a blog post for another time 😉

Covering All The Bases

As always, it is possible you may have a case where following the linter rule doesn’t seem to work. This particular comment from the linter issue lists many edge cases and common scenarios in case you have a non-standard case.

Additionally, the performance section of the official React Hooks FAQ also covers some interesting situations.

But after correcting my original mistakes in my codebase, I realized that every time I thought to disable the lint rule, I was actually able to rewrite the code in a more correct / stable manner. I think this is what Dan Abramov means when he says following the rules of hooks should nudge you into writing better patterns.