A major part of why I moved my personal blog to next.js was to seamlessly integrate static blog posts and interactive code examples. While there's certainly no shortage of great development content in the world, it's rare to find great content and easy to follow visuals in the same spot.
To kick off this new blog paradigm, I thought I'd dive into the React useEffect hook. Effects are incredibly powerful but they can be difficult to grasp without seeing what's actually going on. To get the most out of this particular post, I would recommend viewing it on a larger device (tablet or laptop).
Effects With No Dependency Array
Let's start off with a simple example where we only give the useEffect function one parameter. This will be a simple component where we have a box. Every time the mouse enters the box, the component adds to the mouse enter count. And every time the mouse leaves the box, the component adds to the mouse leave count. We'll also keep a count of all the times that our effect is run.
export function Component() {
const effectInvocationCount = useRef(0);
const [mouseEnterCount, setMouseEnterCount] = useState(0);
const [mouseLeaveCount, setMouseLeaveCount] = useState(0);
useEffect(() => {
effectInvocationCount.current += 1;
});
function onMouseEnter() {
setMouseEnterCount((previous) => previous + 1);
}
function onMouseLeave() {
setMouseLeaveCount((previous) => previous + 1);
}
const isHovered = mouseEnterCount > mouseLeaveCount;
return (
<div>
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<p>{isHovered ? "Is Hovered" : "Is Not Hovered"}</p>
</div>
<div>
<p>Mouse Enter Count: {mouseEnterCount}</p>
<p>Mouse Leave Count: {mouseLeaveCount}</p>
<p>Use Effect Invocations: {effectInvocationCount.current}</p>
</div>
</div>
);
}
And here's the actual component in action. Use your mouse to enter and leave the purple box to see how it works.
Is Not Hovered
Mouse Enter Count: 0
Mouse Leave Count: 0
Use Effect Invocations: 0
A couple of things that you might notice here. For one, the effect count is always greater than the sum of the mouse enter and mouse leave counts. Why? Because effects get run after the first render which occurs before we've interacted with the component.
Another thing that's important to notice is in our effect, we're setting a property on a ref and not calling any set state functions. If we were to set state in our effect and not provide any effect dependencies, we would cause an infinite loop.
To show an example of the issue I'm describing, I've created a component that slows down the process a little bit by only running the loop every 500 milliseconds. In a real world scenario, this is going to run thousands of times and cause all kinds of problems.
export function Component() {
const [effectInvocationCount, setEffectInvocationCount] = useState(0);
useEffect(() => {
setTimeout(() => {
setEffectInvocationCount((previous) => previous + 1);
}, 500);
});
return (
<div>
<p>Use Effect Invocations: {effectInvocationCount}</p>
</div>
);
}
Use Effect Invocations: 0
The effect sets state which triggers an effect. The effect then sets state and retriggers the loop. It's uncommon to run into this problem though because we rarely use an effect to respond to every component render. It's far more common to supply the optional second parameter, an array of comparison dependencies.
Effects With a Dependency Array
The simplest example to show is when we only want a hook to run when the component mounts. I tend to use this all over the place to fetch data when a user lands on a particular page or first accesses a given component. In this example though, we'll just do something synchronous.
To ensure the effect only runs when the component mounts, we provide an empty array as the second parameter to our hook.
export function ParentComponent() {
const [componentIsVisible, setComponentIsVisible] = useState(false);
function toggleComponentVisibility() {
setComponentIsVisible((previous) => !previous);
}
const onClickText = componentIsVisible
? "Unmount Component"
: "Mount Component";
return (
<div>
<Button onClick={toggleComponentVisibility} type="button">
{onClickText}
</Button>
{componentIsVisible && <ChildComponent />}
</div>
);
}
function ChildComponent() {
const [effectHasBeenRun, setEffectHasBeenRun] = useState(false);
useEffect(() => {
setEffectHasBeenRun(true);
}, []);
return <p>Effect Has Been Run: {effectHasBeenRun ? "True" : "False"}</p>;
}
This one is slightly more complicated than our initial example so let's talk through it. In ParentComponent, we provide a button that toggles a boolean piece of state. If the value is true, we render the ChildComponent so as to see our effect behavior on component mount.
Mount and unmount the component to see it in action.
If you take a look at the initial state of the ChildComponent, you'll notice that the effectHasBeenRun variable defaults to false. However, after the initial render our useEffect runs and sets the state to true and the rendered text is "Effect has Been Run: True".
Now that we've covered how to trigger an effect with every render and for only the first render, let's get into triggering effects providing parameters to the dependency array.
The first thing to understand here is React will do an internal comparison of parameters between renders to see if running the provided callback is necessary.
To illustrate this, we'll set up a simple component with three buttons where our effect runs based on the color state of the first two buttons. We'll then pass a function to each button. The first and third buttons will toggle their color state on click while the second button will just retain its state on click.
export function Component() {
const [firstButtonIsPrimaryColor, setFirstButtonIsPrimaryColor] = useState(
true
);
function toggleFirstButtonIsPrimaryColor() {
setFirstButtonIsPrimaryColor((previous) => !previous);
}
const [
secondButtonIsPrimaryColor,
setSecondButtonIsPrimaryColor,
] = useState(true);
function retainSecondButtonIsPrimaryColor() {
setSecondButtonIsPrimaryColor((previous) => previous);
}
const [thirdButtonIsPrimaryColor, setThirdButtonIsPrimaryColor] = useState(
true
);
function toggleThirdButtonIsPrimaryColor() {
setThirdButtonIsPrimaryColor((previous) => !previous);
}
useEffect(() => {
/*
run our effect
notice here that only the first button state and
the second button state should trigger the effect
*/
}, [firstButtonIsPrimaryColor, secondButtonIsPrimaryColor]);
return (
<div>
<Button
onClick={toggleFirstButtonIsPrimaryColor}
isPrimary={firstButtonIsPrimaryColor}
>
Toggle Color (1)
</Button>
<Button
onClick={retainSecondButtonIsPrimaryColor}
isPrimary={secondButtonIsPrimaryColor}
>
Toggle Color (2)
</Button>
<Button
onClick={toggleThirdButtonIsPrimaryColor}
isPrimary={thirdButtonIsPrimaryColor}
>
Toggle Color (3)
</Button>
</div>
);
}
On the left you'll see the actual component and on the right we'll log the state of the component at the time the effect callback is invoked.
There are a couple of interesting pieces here. First, the effect is not run after every render. To see what I mean, click the third button over and over. You'll notice that nothing is logged to our table even though the state of the component is changing. Why? Because we didn't include the third button state in our dependency array.
Another thing to note is the effect never runs when we click the second button either. This is because the second button state never changes, even on click. The provided effect callback runs after the component (re)renders AND when the dependency array changes.
So if the true value of our dependency array started like this:
const dependencyArray = [true, true];
and changes to this:
const dependencyArray = [true, false];
the effect will run.
A key area of misunderstanding for new developers is the difference between primitives like a boolean value and non-primitives like an object, and the effect that those types of values have on effects. Consider the following code:
const obj1 = { hello: "world" };
const obj2 = { hello: "world" };
const objectsAreEqual = obj1 === obj2;
// false
In order to preserve speed, React only does shallow comparisons of the values we provide in our dependency array. If we provide arrays and objects as importance, we also need to ensure the underlying object id changes (usually using the spread operator) so as to trigger our effect at the right time.
To illustrate this further, I'll show one more example. Here we've got a simple button. Every time we click the button, we create a new object with the exact same values as the first. On the left, I'll show the component and on the right, I'll keep a log of the triggered effects and the component state at the time of the effect.
export function Component() {
const [object, setObject] = useState({
firstName: "Gandalf",
lastName: "The Gray",
age: "2019",
});
function updateObject() {
setObject((previous) => {
return {
...previous,
};
});
}
useEffect(() => {
// run our effect
}, [object]);
return (
<Button onClick={updateObject} type="button" isPrimary={true}>
Update Object
</Button>
);
}
Notice that none of our object values are changing. But we are creating a new object each time so the effect is running.
Canceling Effects
The last thing we need to cover is how to cancel effects. More often than not, we're using effects to handle async behavior like debouncing an input or making a request to our api when a particular component mounts.
But what happens when we change the parameters to a api request while the request is mid-flight? Or what happens when a user navigates away from a component before we finish loading all of the data for the page?
If we don't handle these situations right, we'll be setting state on components that no longer exist in the DOM.
Let's create a simple example to mimic how an api would work. First, we'll set up our fake api function like so:
function getData(): Promise<IData[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{
firstName: "John",
lastName: "Doe",
},
]);
}, 2000);
});
}
This function returns a promise that wraps our data. After two seconds, the promise will resolve so that other functions in the promise chain can work on it.
Now let's pretend we have a component that when mounted, makes a request to get this data and then displays it like so:
export function Component() {
const [showComponent, setShowComponent] = useState(false);
function toggleComponentState() {
setShowComponent((previous) => !previous);
}
return (
<div>
<Button
onClick={toggleComponentState}
type="button"
isPrimary={true}
>
{showComponent ? "Unmount Component" : "Mount Component"}
</Button>
{showComponent && <InnerComponent />}
</div>
);
}
function InnerComponent() {
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState<IData[]>([]);
useEffect(() => {
getData().then((data) => {
setIsLoading(false);
setData(data);
});
}, []);
return isLoading ? (
<Typography type="body2">Component Is Loading</Typography>
) : (
<Table data={data} />
);
}
And here's the component in action:
At first glance, it may seem like this component is set up just fine. But there's a key flaw: what if the component unmounts before it finishes loading the data? To see this in action, go back to the component, mount it, then click the button to unmount the component before the data comes back.
While React won't show the error on the page in production (the error is stripped from the production bundle), we're in danger of introducing memory leaks to our application.
Lucky for us, the fix is simple. Taking advantage of Javascript closures, we create a didCancel variable and alter the value of this variable if a new effect is triggered, effectively canceling the old one. It's a really small adjustment to our code, and I've added numbered comments so you can follow along.
export function Component() {
const [showComponent, setShowComponent] = useState(false);
function toggleComponentState() {
setShowComponent((previous) => !previous);
}
return (
<div>
<Button
onClick={toggleComponentState}
type="button"
isPrimary={true}
>
{showComponent ? "Unmount Component" : "Mount Component"}
</Button>
{showComponent && <InnerComponent />}
</div>
);
}
function InnerComponent() {
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState<IData[]>([]);
useEffect(() => {
// 1. We create a variable scoped to this callback.
let didCancel = false;
getData().then((data) => {
/*
3. If the component hasn't unmounted and another effect
hasn't been sent off, our didCancel variable will remain
false and we'll alter the component state. If the component
has unmounted or another has been sent off, our didCancel
variable will be set to true and the callback will
short-circuit here.
*/
if (didCancel) return;
setIsLoading(false);
setData(data);
});
/*
2. When a new effect is triggered, React will invoke the
function that was returned from the previous effect.
This let us override the value of the scoped didCancel
variable.
*/
return () => {
didCancel = true;
};
}, []);
return isLoading ? (
<Typography type="body2">Component Is Loading</Typography>
) : (
<Table data={data} />
);
}
Now try the same thing on this component:
We've effectively resolved our memory leak! Now a word of caution here. Just because we named our variable didCancel doesn't mean that the asynchronous portion of the effect is canceled. Once we trigger an api request from our effect, there's no way to call it back from the frontend code.
It's best to think of canceling effects as cancelling the frontend impact of those effects. Taking this into account, it's no wonder we often use effects to debounce api calls. Sending off near simulataneous requests with different payloads opens the door for all kinds of race conditions.
I hope this gave you a taste of what to expect with hooks. Using these principles, you should be able to create all kinds of amazing combinations in real world scenarios. Good luck!