This is the fourth and final article on the difference between class-based and functional components. These articles are intended to help React developers who are making the switch from classes to functions, and find themselves irked when some of the mental models they’ve used with class-based components fail them with functional components.
The other articles in the series are as follows:
- This Ain’t Your Grandmas’s React: How Hooks Rewrote Your Understanding of Components
- This Ain’t Your Grandma’s React II: How State Works Without Classes
- This Ain’t Your Grandma’s React III: How Hooks Handle Dependencies
If you haven’t already read the first three, I’d recommend at least reading Parts One and Three. This part relies heavily on the discussion of closures and dependencies discussed in Part Three, and Part One will give you an overview of the differences.
Part Four is really the most exciting and different part of functional components. With class-based components, you create side effects in
componentDidUpdate, and clean them up in
componentWillUnmount. With functional components, you create and break down individual side effects with
useLayoutEffect. The paradigms are very different. So different that, when I hear, “How can I reproduce
componentDidUpdate with Hooks?” I ask, “Why would you want to?”
Let’s start by introducing the Hooks, then we’ll get into why they shift how we think of side effects, for the better.
useEffect: Your Go-To Side Effect Creator
Instead of creating a side effect in a class’s
componentDidUpdate methods, functional components use the
Let’s take a look at how
useEffect is used to set up a 5-second update on a user’s score:
There are three things to focus on in the above example: The closure, the closure’s returned function, and the dependency array.
The closure itself is the side effect you want to create. With
useEffect, the closure is run after the DOM is rendered and all the refs are reconciled.
useEffect side effects run asynchronously, after the page is first painted. This is a critical difference from
componentDidMount, which runs synchronously before first paint. The asynchronous nature of
useEffect makes the page significantly more responsive than when creating side effects through
useEffect’s closure can return a function. This returned function lets you clean up the closure’s side effect. It will be run every time the dependency array changes, and when the component unmounts. Note that, since it can be defined as a closure created by the effect, it can take advantage of any variables defined by the effect when it is executed.
The last item, the dependency array, tells
useEffect when it should clean up the previous closure and restart it with updated values. If you provide an empty dependency array, React knows that nothing will cause this effect to change. In that case, it will run once, when the component is first created, and any provided cleanup function will only run when the component is unmounted.
So, looking at the example above,
useEffect’s closure creates an interval that updates a user’s score every 5 seconds. This side effect is dependent upon
userId is updated, the closure’s returned function clears the existing polling interval, taking advantage of its closure to get the
intervalId created by the initial effect. Once it is cleaned up, the effect is run again, using the new
userId value to generate another polling interval. When the component finally unmounts, the returned cleanup function is called a final time, to clean up that final interval.
Notice how little the component lifecycle is discussed in this description. We are only interested in the side effect lifecycle. Hooks make side effects effect-centric instead of component-centric.
useLayoutEffect: Use It If You Must
useEffect is the go-to for creating side effects, why is there
useEffect generates side effects asynchronously after first paint,
useLayoutEffect generates side effects synchronously, before first paint. This can be useful if a piece of presentation flickers when the component is first rendered, but more often than not, this won’t be an issue.
useLayoutEffect is performed synchronously before paint, it can dramatically impact your application’s responsiveness. Complex calculations or I/O initialization will make your application feel slow and laggy if done in too many places. This is the primary issue with class-based components who use
componentDidMount, which is also synchronous.
useEffect was created specifically to improve this situation.
So, if you see that flicker and it’s annoying, go ahead and use
useEffect will make your application feel smoother.
So, I lied. You can kind of recreate
componentDidMount/componentDidUpdate. But moving from back to a lifecycle-centric, synchronous side effect creation model doesn’t make sense when you can make side effects that focus on the side effect and don’t interrupt the component’s responsiveness. The next section goes into the details.
Why Functional Components’ Side Effects Don’t Work Like Class-Based Components
So why do
useLayoutEffect cause such confusion to developers accustomed to class-based components?
In a class-based component, you have three places to deal with side effects:
componentDidMount— after the DOM is rendered the first time
componentDidUpdate— after the DOM is rendered through its lifecycle
componentWillUnmount— before the component is destroyed
This leads developers to think in these terms:
- What side effects do I want to initiate at component startup?
- How do I want to change the component or modify its side effects during its lifecycle?
- How do I want to clean everything up when the component is finished?
useLayoutEffect, side effects are queued when the component is first rendered, cleaned up when the component is unmount, and cleaned up and recreated whenever their dependencies change. In addition, whenever a dependency changes, these Hooks give you the opportunity to clean up the side effects within the context of the side effect being created. This leads developers to think in these terms:
- What side effects does this component need?
- In a given side effect, are there dependencies that should force the side effect to change?
- How do I want a given side effect to clean itself up?
Class-based side effects tend to be based on the class’s lifecycle. Hook-based side effects tend to be based on the side effects’ lifecycles.
This distinction can make your code much cleaner. With class-based components, you have to manage all of your side effects in three methods, spread across your lifecycle methods.
Say you have 3 different side effects, A, B and C. A and B need to be cleaned up before the component unmounts. In addition, B needs to be updated if a property changes.
With a class-based component, you might end up with something like this:
This is about as clean as it can get. The side effect creation and cleanup is separated out into individual functions, making the lifecycle functions easier to read, but you still have the side effects spread across these functions. It could be pretty easy to miss that side effect C was started, but didn’t need any cleanup.
With functional components, the
useEffect Hook encapsulates all information about a particular side effect. The same scenario above looks like this:
Here, the first Hook is devoted solely to side effect A — its creation and cleanup. The second Hook is devoted to side effect B’s creation, cleanup, and dependencies. The third is devoted to side effect C’s creation.
As each side effect updates the component’s state independently, we can let the component rerender as each update comes in, letting the side effects only care about themselves. If one side effect is dependent upon the results of another side effect, it can identify the state impacted by the second effect as a dependency, causing the first effect to reset when that state changes.
Hooks-based side effects are designed to elegantly manage, encapsulate and isolate external state changes. The changes could be siloed or interdependent upon each other, but the dependencies are clearly stated right after the side effect itself.
Use Refs As an Escape Hatch for Closures
Sometimes you create a side effect that actually requires the latest information from the DOM or from other side effects. You might have data updating so frequently that it would flood React with rerenders and side effect resets if you updated state to update your effects. In circumstances where you need fluid feedback, use a ref.
The refs themselves are unchanging. They don’t even need to be included in the dependency array. (React will even complain if you try). In fact, refs can’t be used to trigger rerenders, even if you try to make its
current field a dependency. (React will complain about that, too.) However, the
current field of the ref is available and can always be updated and read in that moment.
In the following example, this Scroller component is supposed to create a scrollable panel whose content is a child. If the child is replaced by a different child, the new child component will remain scrolled to the same location as the previous child.
scrollTopRef.current in the scrolling event handler, it gets constantly updated, regardless of how much or how fast the scrolling occurs, without forcing any rerenders or updates. When child component is updated, the event handler is removed, and after the new child is rendered, the scroller’s
scrollTop is reset to where it was before it lost its prior child, and a new event handler is added.
(Admittedly I have no idea if this is actually necessary. It’s midnight and I just needed an example that illustrated how values can be updated and used without rerendering.)
Be aware, though, that if you find yourself constantly circumventing the dependency array with refs, you may want to reconsider your code, to understand why dependency arrays aren’t giving you what you need most of the time.
Hopefully this article illustrated a big difference between class-based and functional components: how they manage side effects.
- Class-based component side effects focus on component lifecycle. Side effects created by
useLayoutEffectfocus on side effect lifecycle.
useEffectlets us create side effects asynchronously, improving app responsiveness.
- Dependency arrays let us reset and update our side effects based upon changed values.
- Refs are an escape hatch to let us update values without resetting side effects.
And that completes my series on comparing React function and class components. Focus on the constants, closures, and dependencies, and how side effects are handled differently, and enjoy this new, lighter world of React component development.
This was a lot of fun to write. I appreciate you sticking it out with me through these novels. I hope you found it helpful!