Improve your knowledge of React’s useEffect with 5 exercises

Taylor Galloway
Red Squirrel
Published in
7 min readJan 21, 2023

To get the most out of this blog post, you should have some basic familiarity with React and useEffect.

I first learned React without the use of hooks. I created class components and functional components. I performed async operations with lifecycle methods like componentDidMount. I memorized the lifecycle of a component in order to understand which lifecycle methods I should use at what times.

With the introduction of the hooks API to React, the fundamentals of the framework seemed simpler to me. No longer was there a difference between class and functional components. My knowledge of a component’s lifecycle and associated lifecycle methods could be exchanged for knowledge of one hook: useEffect.

At first,useEffect appeared simple enough: pass in a function and an array with variables as arguments. When the values in the variables change, the function runs. Unfortunately, my first impression proved to be incorrect. As I worked with the hook across contexts, I realized greater knowledge would be necessary to proficiently use it.

If I’ve learned anything about deeply understanding a complex process in code, it’s that it’s critical to do more than read about it in the abstract. It’s best to type the code yourself, see it applied in multiple situations, and try to apply it in novel situations without blindly copying someone else’s code. Given that, I hope to improve your understanding of useEffect by presenting various code snippets where this hook is applied incorrectly. Try to fix it yourself first, and then read the explanation!

Mistake #1

If you are familiar with when to use useEffect perhaps this first example will be simple for you. Either way, it is helpful for illustrating a fundamental concept of when to use this hook. (If you haven’t used CodeSandbox before, you can edit the code below and you can drag the bar on the right to see the UI preview of the code.)

Before reading any further, try to figure out the mistake first!

The mistake here is calling useEffect instead of an event handler in response to a user interaction. No useEffectis actually needed.

In this example, when a user clicks the submit button, the submitForm state is set, the component re-renders, the useEffect notices that submitForm has changed values, it invokes the function passed to it, makes a network request (mocked), the state is reset again, and another re-render is triggered.

If instead, the post had been called directly after the user clicked the submit button, the submitForm state could be removed, and the extra re-render of the component could be eliminated.

This example highlights the importance of understanding when useEffect should be used. useEffect is for triggering code in response to the screen updating, not for triggering code in response to an event, such as a user interaction.

Example of an event handler you could use to replace the useEffectabove:

const handleSubmit = () => {
makePost().then((response) => {
if (response.ok) {
setUserInfo({ name: "", email: "" });
}
});
};

View my full solution here.

Mistake #2

Your goal with this example is to remove the memory leak and to also allow the user to click multiple buttons while only showing the result of the last button pressed. Experiment with the UI to see the current incorrect behavior.

The main problem here is that the useEffect is missing a cleanup function. A single useEffect represents a synchronization process that React has with an external system. The code inside the function passed to useEffect represents the beginning of the cycle, so there needs to be some way to signal the end of this synchronization.

This is done with a cleanup function which is returned at the end of the function passed to useEffect. Its job is to “clean up” anything that the code in useEffect had left open. In this case, a timer was set and would continue to be in operation unless there was a clean-up function to clear it out. Not clearing out this timer (or code put in useEffect) could result in memory leaks, race conditions, or the wrong code running after a user has left a page.

Here’s what the useEffect from above looks like with the cleanup function:

useEffect(() => {
let timer = setTimeout(() => {
setDisplaySuccess(sendMessage);
}, 1000);
return () => clearTimeout(timer); /*cleanup function*/
}, [sendMessage]);

View my full solution here.

Mistake #3

In this example, you only want to send a notification if the status on an alert has changed values. However, if you refresh the UI preview, you’ll see that the notification is currently “sent” twice. This is because the useEffect in AlertStatus.jsx on line 7 executes its function even though the status property on the alertDetails object never changes values. Can you figure out why this happens?

The code inside this useEffect is running too many times because React sees the alertDetails object as a different object after each render even though the values in the object have stayed the same.

The point of the dependency array is to control the execution of the function passed to useEffect. The function is only invoked after the first render of the component and when one of the variables in the dependency array has changed values. So you would think that if the properties in the object had stayed the same between renders, React would not run this code. However, when objects are assigned to variables in JavaScript, only a reference to that object is stored rather than a deep copy of that object. When setState is run, a new object is created and the variable associated with the previous object now points to this new object. So technically the variable alertDetails is pointing to a “new” object even though the values inside have not changed.

In this particular case, a solution you can implement is to destructure the object down into primitives and put those new variables in the dependency array. Then useEffect will only run when the primitive values in these variables change. React suggests avoiding having objects (and functions) as dependencies, but if you have to, you can declare objects within useEffect itself, or outside of the component. If an object is passed as a prop or declared within the component body, it will technically be recreated on each re-render, and will probably make your useEffect run more frequently than you want.

Here’s what the deconstructed object with the relevant useEffect looks like:

const { status } = alertDetails;

useEffect(() => {
if (status === "critical") {
setNotificationCount((prevCount) => prevCount + 1);
}
}, [status]);

View my full solution here.

Mistake #4

This example is imitating two network requests which both are run in the useEffect. Can you identify any unwanted behavior here?

The unwanted behavior here is that when selectedCampsite changes values, the getCampsitesfunction will be called even though that has nothing to do with the change in selectedCampsite. Selecting a campsite should retrieve details about that campsite rather than grabbing the list of campsites again. Essentially, two separate synchronization processes have been compressed into one useEffect.

Instead, each useEffect should represent a separate cycle connecting React to some external process. In this case, each network request connects React to a resource on a server it tries to stay in sync with. getCampsites and getCampsiteDetails connect to separate resources and the reasons for re-syncing depend on different things changing. These should be in separate useEffect calls.

Sample of my solution:

useEffect(() => {
getCampsites();
}, []);

useEffect(() => {
if (selectedCampsite) {
getCampsiteDetails(selectedCampsite);
}
}, [selectedCampsite]);

View my full solution here.

Mistake #5

The last mistake to cover. Another inefficiency in this useEffect.

This useEffect can actually be removed entirely. It does not synchronize React to some external system or execute some behavior in response to the screen being displayed to the user. It manipulates an existing piece of data to be rendered to the screen. It filters the recipes list to only show favorites.

By waiting to set favoriteRecipes in this useEffect, the first render of this component is displaying favoriteRecipes when it is an empty array. Future renders will show favoriteRecipes with stale data from its previous state before it’s updated with useEffect.

Any time you display data that can be calculated from the existing state switch to calculating it in the component body. If the calculation is expensive in some way, then useMemo can be used to ensure that the calculation is only made when necessary.

Example of calculating favoriteRecipes outside of useEffect:

const favoriteRecipes = recipes.filter((recipe) => recipe.isFavorited);

View my solution here.

I hope this was a helpful opportunity to deepen your knowledge of useEffect. In general, this hook is for running code which needs to happen after the screen updates. This is a good time to start synchronization with some system outside of React whether that’s a remote server or another browser API. It is helpful to think of this synchronization as a cycle that has a starting point and an ending point. Your job when writing a hook properly is to implement the beginning and end of that cycle through proper use of the dependency array and cleanup function.

If you’re interested in learning more, please check out the new beta React docs. They are incredibly clear and they provide exercises to test your knowledge of this useful but complex function.

--

--