simeonGriggs.dev
Twitter
GitHub

The guide to useEffect() I wish I had

If you're relatively new to React, Hooks aren't new, they simply exist. And to understand the useEffect() hook is to almost entirely understand React Components.

View in Sanity Studio

2020-12-10

I can't recall when I first felt like I understood React. I certainly tried it a number of times before it finally clicked. And when it clicks, the feeling is electric.

At some point I had to stop trying to understand Classes vs Hooks. Since I was never using React before Hooks – I didn't understand what problems Classes had that Hooks were designed to solve.

Forget trying to figure that out and understand these three things are true:

  1. Hooks are the primary way of processing logic in React.
  2. To understand useEffect() is to understand 90%* of React.
  3. useEffect() is an awful name for what the function actually does.

Rendering and Re-rendering

React projects are an assortment of Components. Individual files which contain an amount of markup and logic.

You could make a Form with Components. You'd probably split its Labels, Inputs and Buttons into Components, too.

Every time a Component is displayed, it is "rendered" onto the DOM.

However with so much reactivity, logic and Components-in-Components, each Component may be regularly re-rendered and even conditionally removed.

These events are referred to as the lifecycle of a Component.

Consider the following <Button /> component:

import React from "react";
const Button = ({ href }) => {
console.log("Button rendered");
return (
<a className="button" href={href}>
{children}
</a>
);
};
export default Button;

Above the return we have a console.log which will be run every time the Component is rendered. Why? Any variable directly in the function, before the return statement, will be reassigned on every render

If you drop this component into even a basic React application there's a chance you'll see "Button rendered" logged to the console multiple times.

Perhaps the parent Component of Button changes, causes a re-render, and our console.log runs again.

That's not a big deal for something as simple as a console log. But if your Component performs some complex function or animation on every render, this can fast become a performance problem.

And if this function returns the same data every time, it's wasteful. Let's look at another example.

Here is a Component <PriceDisplay /> which shows a product price, but needs to calculate if this product qualifies for free shipping. In this example the checkFreeShipping() function is running every render, even if we only need to work this out once.

import React from "react";
import { checkFreeShipping } from "./lib/checkFreeShipping";
export default function PriceDisplay({ price }) {
const freeShipping = checkFreeShipping(price);
return (
<span>
${price} {freeShipping ? `Free Shipping!` : ``}
</span>
);
};

This is where useEffect() comes in. It is used to monitor the current state of the Component – and variables within it – to perform actions only when they change.

And though useEffect() is so fundamental to understanding React, it's not the most obvious function to understand on the surface.

The dependency array

useEffect() runs after the Component is first rendered.

So here's the same Component as above, but checkFreeShipping() only runs once, no matter how often the Component is re-rendered.

import React, { useEffect } from "react";
import { checkFreeShipping } from "./lib/checkFreeShipping";
// This is a *bad* example, don't copy/paste it!
export default function PriceDisplay({ price }) {
let freeShipping = false;
useEffect(() => {
freeShipping = checkFreeShipping(price);
}, []);
return (
<span>
${price} {freeShipping ? `Free Shipping!` : ``}
</span>
);
};

Let's consider the lifecycle of this Component:

  1. It first renders with freeShipping as false, then...
  2. After the first render, freeShipping is set to the value of whatever checkFreeShipping() returns, but...
  3. If the Component renders again, freeShipping is set back to false.

I'll explain 1-2 now, we'll solve 3 after.

Again, useEffect() only runs after the first render.

How many more times it runs is determined by the dependency array.

Shown on the last line in these examples:

// 1. useEffect runs only after the first render
useEffect(() => {
freeShipping = checkFreeShipping(price);
}, []); // Empty dependency array
// 2. useEffect runs every time the `price` variable changes
useEffect(() => {
freeShipping = checkFreeShipping(price);
}, [price]); // Variable in dependency array
// 3. useEffect runs every re-render
useEffect(() => {
freeShipping = checkFreeShipping(price);
}); // No dependency array

Important! The first example is not a hack. Sure, it looks weird, but it's totally fine! Making a Component run some logic on first render is something you'll do often. An empty dependency array on useEffect() is the way to do it.

So here we've solved needless re-rendering. But we have a new problem, if the Component re-renders, freeShipping will return to false.

The second example inserts a variable inside the array. This will trigger useEffect() to run whenever this variable changes.

Imagine a Coupon being applied to all products on the page. The price changes in all these Components, so useEffect() runs again, and freeShipping may be updated too.

This is the most powerful use of useEffect() but also a potential pitfall, see Infinite re-rendering.

The last example has no dependency array and so re-runs every render. Which is precisely what we're trying to avoid. So ... avoid doing this.

Now we just need to address the issue of inadvertently overwriting our freeShipping variable. This is where our friend useState() comes along for the ride.

import React, { useEffect, useState } from "react";
import { checkFreeShipping } from "./lib/checkFreeShipping";
// This is an *okay* example, keep scrolling!
export default function PriceDisplay({ price }) {
const [prevPrice, setPrevPrice] = React.useState(price);
const [isFreeShipping, setIsFreeShipping] = React.useState(() => checkFreeShipping(price));
if (prevPrice !== price) {
setIsFreeShipping(checkFreeShipping(price));
setPrevPrice(price);
}
return (
<span>
${price} {freeShipping ? `Free Shipping!` : ``}
</span>
);
};

Storing our freeShipping variable with useState() means its value will only ever be changed by the setFreeShipping() function. But with the same false default on the first render.

Note: You could run checkFreeShipping() inside the initial useState() so it is correctly set on the first render. But for the sake of this tutorial I'm trying to keep the syntax simple!

Things you can't do

Occasionally you'll be warned by React you're "breaking the rules of hooks". Often this is from condtionally calling Hooks.

For some reason, you're not allowed to do that.

// Wrong! Will error
if (price) {
useEffect(() => {
setFreeShipping(checkFreeShipping(price));
}, [price]);
}
// Correct! No errors
useEffect(() => {
if (price) {
setFreeShipping(checkFreeShipping(price));
}
}, [price]);

Infinite re-rendering

Another easy mistake is to both monitor and modify a variable with useEffect(), like this:

useEffect(() => {
setFreeShipping(checkFreeShipping(price));
}, [price, freeShipping]);

The logic here is: If price or freeShipping change, run setFreeShipping, which changes freeShipping ... which will infinitely re-render.

Some linting rules will automatically duplicate every variable inside your useEffect() into the dependency array. This is sometimes useful. But at other times, incredibly annoying.

useMemo() as a replacement of useState() + useEffect()

It's a common pattern to:

  1. Create a new instance of useState()
  2. Only ever update it by running logic within useEffect()

But there is another React Hook, useMemo() which can sort-of combine the two.

There's two reasons we might do this. One is better performance.

The other is a more obvious and concise syntax.

Invoking useState() is a signal that we need and will use piece of reactive state. But our example Component is never going to modify freeShipping itself. It will only ever change if the price prop passed-in changes.

import React, { useMemo } from "react";
import { checkFreeShipping } from "./lib/checkFreeShipping";
// This is a *better* example!
export default function PriceDisplay({ price }) {
const freeShipping = useMemo(() => {
return checkFreeShipping(price);
}, [price]);
return (
<span>
${price} {freeShipping ? `Free Shipping!` : ``}
</span>
);
};

Like useEffect(), useMemo() has a dependency array which will ensure it is updated when price changes. But the key difference is that useMemo() returns the output of your logic to whatever variable you're setting.

This example is much more obvious that all we want to do is to update freeShipping when price changes.

Custom Hooks

We can make this even cleaner!

Any function that starts with use can be a React Hook. This means we can move our logic into its own separate (and re-usable!) file which will be just as reactive as React's in-built Hooks.

Our little useMemo() hook above wasn't really complex enough to get the benefits of this. But if you have some large, complex function that you'd like to conditionally update inside Components, Custom Hooks are an excellent way to organise them.

// ./lib/useFreeShipping.js
import { useMemo } from "react";
import { checkFreeShipping } from "./checkFreeShipping";
export function useFreeShipping(price) {
return useMemo(() => {
return checkFreeShipping(price);
}, [price]);
}
// ./components/PriceDisplay.js
import React, { useMemo } from "react";
import { useFreeShipping } from "./lib/useFreeShipping";
// This is the *best* example!
export default function PriceDisplay({ price }) {
const freeShipping = useFreeShipping(price);
return (
<span>
${price} {freeShipping ? `Free Shipping!` : ``}
</span>
);
};

Returning

Equally as not-obvious as the dependency array is the use of return inside useEffect(). Whatever you return will be run when the Component unmounts.

This isn't useful in our examples here, but becomes useful for something like an event listener. You'd want to attach this listener to the document when the Component renders, but remove it once the Component no longer exists.

useEffect(() => {
document.addEventListener("keydown", handleKeydown());
return () => document.removeEventListener("keydown", handleKeydown());
}, []);

Failure to clean up your useEffect() like this can result in errors where React is attempting to run functions in Components that no longer exist.

Footnotes

*Okay so perhaps I've over-sold the importance of useEffect() as it relates to using React.

And in this very blog post we've already looked at an instance where other Hooks would be more useful.

But it remains true that if your time with React has only just started, understanding and confidently using useEffect() will be the fastest path to you actually enjoying your time with React.