Honest Programming with useEffect
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.
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
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.
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.