Photo by Cristian Escobar on Unsplash

Honest Programming with useEffect

useEffect is a seemingly simple function, but it requires a non-trivial knowledge of React and of Javascript in order to use it well. Programmers of all experience levels find themselves grappling with the hook at one time or another. One reason it is tricky is because many people who are familiar with the lifecycle methods from React’s older APIs (myself included) find themselves trying to translate useEffect back into componentDidMount, componentDidUpdate etc, only to find that useEffect is not new syntax for this paradigm, but a new paradigm itself.

In the examples below, I’ll show you a few patterns I’ve encountered in my own experience as well as improvements to make React code more idiomatic, less cognitively complex, and less buggy.

Lying to useEffect

Lying is what Dan Abramov calls it when developers mis-report dependencies in order to coerce useEffect into functioning more like a lifecycle method. It’s possible, but it breaks the useEffect paradigm, adds complexity, and can hide bugs.

Here’s an example. Let’s say we want to render a list of todo items, and we also want to display the category that each item belongs to. We get some information from props, and then we want to add in category data from a custom hook and pass the new object to a display component.

We’ll store the editable items as state because this component needs to make updates to them, and maybe downstream components do too.

The key thing to note here is that we’re using useEffect and we’ve given it an empty dependency array (line 43), and because we’ve done that, we also disabled the lint warning telling us we are missing dependencies.

In doing this, we’ve forced the useEffect to run only once. This allows us to get our data in the right shape the first time the component mounts. However, this is not how useEffect wants to behave. useEffect does not deal with timing in terms of the component lifecycle; it responds to changes in data in order to cause your component to rerender.

Respecting the Linter

In a recent workshop from React Training, and the instructor gave this rule of thumb: Always listen to your linter when it comes to exhaustive deps for useEffect. If you make the changes that the linter requires and you feel that your code has gotten worse or doesn’t work the way you want, refactor your code.

Rarely do I subscribe to “always”, but so far listening to this advice has served me well. So let’s try it.

If we remove the eslint-disable , we’ll get this warning from the linter:

React Hook useEffect has missing dependencies: 'categories' and 'listItems'. Either include them or remove the dependency array  react-hooks/exhaustive-deps

Easy enough to add those in. Here’s what our useEffect code looks like now:

However, we have a problem. If we run our app locally and look at the console, we’ll see this error:

Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.

Why is this infinite loop happening? As per the error message, we must have a dependency that changes on every render. At first glance, this doesn’t make much sense. The only thing the useEffect is modifying is state, editableItems , but this isn’t in our deps array. It doesn’t need to be because we’re not referencing the editableItems themselves; we’re only calling the set state function that updates them.

Here’s what’s going on. In the first cycle, we call our custom hook to get categories, which is an array of Category objects. Our component renders. Then useEffectgets called, which performs its logic for munging together categories and list items. Because our effect changes state, it then triggers our component to rerender. And each time before we rerender, we call our categories hook, which returns to us a brand new object. In the effect’s dependency array, objects are compared by memory location, not by value. So since categories is a dependency of the useEffect , and the categories object is a new object in memory, this triggers our effect which triggers a rerender, and you can see how we’re now in an infinite loop.

Refactoring

We followed the linter’s advice, and things are certainly worse. Now what? There are a few ways we could get out of this situation, but for this post I’m going to keep our existing structure and only change the useEffect.

Actually I’m going to remove it entirely. Let’s take a step back and remember what we’re trying to accomplish. When the component first renders, we want to combine some data to be used downstream in a child component. At the end of the day, we only want to do this data munging once.

For this, we can rely on useState and dispense with useEffect entirely. It looks like this:

We’ve moved our data-munging logic to a function we declared outside the component, and we were able to simplify our component quite a bit. This reads much clearer without the useEffect , because that hook inherently looks for things that could change, when we know that they can’t. We only want to do some logic one time, and now it’s clear that we are.

Extension — What about when data can change?

So far in this example, we’ve assumed that the result of our custom useCategories hook is idempotent. It’s a hook that performs some logic and returns to us the same list of categories every time it’s called. But what about a variation on this? Let’s say that instead of getting data from a hook, we get it from a context. A context is where we might make an API call to get categories data.

In this example, we know that there will be a period of time when we have no data from the context, while the API call is being made, and then we will have data after it finishes.

In line 40, we subscribe to the context (this assumes our ToDoList component wrapped by CategoriesContextProvider ). On line 43, we pass the category data to initialize our state.

This looks pretty similar to our previous code, but if we run it we find that it’s fundamentally broken. We’ll never see the category names for the editable items in the DOM. This is because to start, categories from the context is undefined because our API call hasn’t finished yet. We initialized our component state with that undefined data. State remains the same across component rerenders unless we update it with a set state function. So even though the value of categories will be different across renders after we get data back from our API call, the state in our component will never change.

How can we ensure that our component responds to changes in data and updates accordingly? useEffect of course. We can add it back in to watch for changes to the context value.

At this point you may be thinking, this looks almost identical to our code that caused an infinite loop. The difference is that the context value is not being redefined on every render. For this example, we only have two states that categories can be in. It can be undefined , or it can be an array of Category data. Once we have data, that array of Category data will be the same object in memory with the same value for the entirety of the component’s lifecycle. So our useEffect works as intended — responding to data that could change and updating our component state accordingly.

Summing Up

useEffect is easy to start using but hard to consistently use well. If you find yourself tempted to lie to the hook, it’s better to take a step back and see if you can clarify your knowledge on any concepts. Along the way, you might have to learn more about React conventions as well as core JS concepts like closures and object references. Which is great! That means by learning more about how React works, we can become better JS programmers generally, and vice versa.

If you’re looking for a really in-depth guide to useEffect and the many nuances of working with it, I encourage you to read this blog post from Dan Abramov. His blog covers similar issues as I have here, as well as alternative solutions to what I have talked about, and many more examples of pitfalls you might encounter when working with the hook.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Erin Greenhalgh

Erin Greenhalgh

Software developer; language enthusiast