The useEffect hook: a brief clarification on its usage

First RTFM, here. Done? Okay, let's go....

I got curious about useEffect and wanted to understand it a bit deeper. So, here we are. According to the documentation (and various, other, sources), this hook should not be used for UI computation (state or prop calculation). It should, however, be used for anything else that falls outside of that general activity. So what does that mean exactly? This is better understood when we better understand how React updates our application. I'm not talking about the low-level detail that I've ventured into in my last post (well, not that fine-grained). No, what I'm discussing here is the work phases that React "goes through" to get your application to change from state A --> state B; or, you clicked a button and the page updated. What happened?

React does its application-updating work in two phases: render and commit.

The first phase, render, is the term used when a component's render method is called and React creates its workInProgress representation of the application (i.e. a mutable Fiber node which it compares to the original). It is in this comparison that it can make the determination on what has changed.

The commit phase, on the other hand, is when React has all the information it needs to create the next representation of the application's state on the DOM itself. In other words, this is the phase that inserts, removes, or updates the DOM nodes themselves.

So where does useEffect come in? Well, let's first understand what it is that useEffect accomplishes then we'll talk about how it works. useEffect would be more clearly (if not more verbosely) named useSideEffect. That gives us a better clue. Alright, I'll cut to the chase...useEffect is what we leverage to run side-effects on our component instances. So, another question comes up:

What's a side effect exactly?

It's okay, if that's not a clear phrase. It wasn't for me, either. A "side effect" is anything that isn't a state (or prop) computation, a computation that can be as simple as incrementing a counter or something much more complex like updating a deeply-nested state object to update some user information. In short, if you want the application to re-render (which happens on state change) you're not talking about a side effect. In React's world "effect" is synonymous with "side effect".

In "traditional" React (React using Class components) we create these side effects in componentDidMount or componentDidUpdate. But, in our "stateless" functional components we don't have those lifecycle hooks, we have...well, hooks. That's what useEffect gives us, the functionality we might be used to with componentDidMount, but without the baggage of a class component. Now we have a functional-component-friendly way to fetch our data or do other work we may have historically done in componentDidMount or componentDidUpdate.

So, when does it run?

By design, useEffect invokes its first arg (a function with your side effect nestled, comfortably, within it) during the commit phase. That means that it won't be run until everything else has been calculated. A brief, and generalized, look at the work process for a simple application's loading would go:

  • render phase begins

Step 1) A new representation of the app is created to compare against the original

Step 2) The work necessary to create this new representation is queued

Step 3) The work is performed based on the new props/state

Step 4) The updated application is commited

  • render phase done / commit phase begins

Step 5) useEffect is run (and other stuff too)

  • commit phase done (UI would reflect that everything is "new")

Step 6) App is now ready for new stuff to happen

That's illuminating, right?! That means, if our linter doesn't catch it, we can get caught in infinite loops. If useEffect tries to update the state (step 5), we're immediately thrown back into step 1 and step 6 is never reached.

This can be explained at a slightly deeper level like so...

useEffect in Fiber

React keeps our application representation in two linked lists (the app with its "old" state, and the app with its "new" state). It will compare these two lists and figure out how to best represent the "new" application state on the DOM. To achieve this quickly, it keeps a list of all the things that it needs to do to go from state A --> state B (each "thing" is considered a unit of work). So, rather than traverse the entire tree--which could get huge--it can go from unit-to-unit and give a really quick update to a pretty complicated app really easily. But, further, it keeps tabs on the specific "effects" (read: "side effects") that should occur as it moves from unit to unit. It is after the work is completed (the state changes have all been computed) that these effects are then run through and pushed onto the page.

Cleanup functions

As the documentation points out, sometimes it's necessary for the component to clean up as it unmounts. That is to say, maybe it needs to (using the documentation's example) "Unsubscribe from a Chat API". Again, with a class-based component we could just use the componentWillUnmount, but...again, we don't have that with the functional components so useEffect allows us to throw a "cleanup function" as a return function in the hook itself. Here's an example:

useEffect(() => {
  // some side-effect code
  return function cleanup() {
    // do some cleanup
  }
})

Like the documentation points out, this could also be an arrow function or plain anonymous function, but it's named here for identification.

Remember when I mentioned that React keeps a list of all the "work" it needs to do to get the application into state B? During the unmounting of a component these steps are still taken, but a new flag is set, namely, Deletion. I'll wave my hands a bit here and put off a deeper explanation for another post, but this is how the unmount process is identified and a different logical path is traversed during the work. Basically, React will traverse this list (formally called updateQueue) and look for any effect with a function attached to its destroy attribute.

And, guess what? Yep...you probably guessed it. The returned cleanup function we defined in useEffect is attached to that attribute. So, React checks if destroy is null and if it is not, it invokes it, which invokes cleanup and our code is executed. Pretty cool.

It's slightly more complicated than that, and I hope to get into the nitty-gritty in part two of my Fiber adventure, but I think this gives a pretty good illustration for how useEffect works and why we shouldn't (we can't, so the point is moot sorta) do any state computation within it.