I had a tryst with the eslint exhaustive-deps rule recently. It made me go down the rabbit hole and realize that I have been using the useEffect hook wrong. In this article, I will be discussing the ill-conceived understanding I had about the useEffect hook and the right way of looking at it.
Before I begin, some context. I started with React development when functional components in React were only stateless components and when we wrote much of the logic using class-based components. When React introduced hooks that allowed functional components to be stateful and encouraged React developers to go classless, I swiftly made the switch like most developers.
The reasons for misunderstanding useEffect
Since I was migrating to functional components from class-based components, I made the mistake of looking at functional components through the prism of class-based components. This precipitated my flawed understanding of the useEffect hook. I can see two major reasons for this misunderstanding in hindsight.
In class-based components, we used lifecycle methods such as componentWillMount, componentDidMount, and componentDidUpdate. In functional components, we have none of these lifecycle methods and we instead have the useEffect hook. One of the two major reasons for my flawed understanding of the useEffect hook was that I saw it as a direct replacement for these lifecycle methods.
Before I talk about the second major reason, let us take a brief look at the useEffect hook. This useEffect hook accepts two arguments. The first argument is a callback function that React calls after a render. The second argument is an array that specifies the dependencies of the hook.
The second major reason was that I looked at this dependency array as a way of conditionally triggering the useEffect hook.
Now, let me elaborate on these two reasons.
Treating useEffect as a replacement for the lifecycle methods
In class-based components, we use the lifecycle methods to perform functions such as network calls. Thus, it is easy to look at useEffect as a one-stop shop for all lifecycle methods in functional components. But the idea behind useEffect is fundamentally different from the lifecycle methods.
The right way of looking at the useEffect hook
In functional components, we use useEffect to synchronize changes to the states and props outside the React tree. Let’s see what that means.
When a component’s state changes, React tries to synchronize this change with the DOM by re-rendering the component. Similarly, the useEffect hook is used to synchronize state and prop changes outside the React tree.
For instance, let’s take the case of a table with pagination. Every time the active page changes, we have to send an API request to retrieve the next set of rows. We can set the active page to a state. and send an API request whenever the active-page state changes. We can do this by sending the API request from within a useEffect hook. Here, the useEffect hook synchronizes the state change outside the React tree by obtaining the right set of rows from the backend.
Hence, we are not bothered about if this API request is sent before or after the component mounts, or on update. This API request is sent every time the state changes. In other words, useEffect synchronizes the state change with the data from the backend. This is the correct way of looking at useEffect.
Now, it is very natural to wonder how we can dispatch an API request only when the component mounts. Well, we can use the dependency array for this. But it is easy to think of this array as something that we use to trigger the useEffect hook. This is an incorrect mental model. And this takes us to the second major reason.
Considering the dependency array as a way to conditionally trigger the useEffect hook
Let’s consider the pagination example again. What would we do to send an API request every time the active page changes in class-based components? We might use the componentDidUpdate lifecycle method and compare the previous active-page state to the current one. Then, we will dispatch an API request if they are different.
With this mindset, one could see the dependency array as a way to ask React to trigger the useEffect hook when the active-page state changes. While this is not exactly incorrect, this mental model can have undesirable consequences.
This mental model works perfectly fine for our example. We can make the API request within the hook and pass the active-page state as a dependency so the hook will fire every time the active-page state changes. However, let’s make a small change to our example and see if this mental model holds up.
This flawed mental model may work but can lead to bugs
Let’s say we also have a textbox that allows you to specify the number of rows to fetch when moving to the next page. We can tie the textbox value to a state. So, we will have to use this state inside the useEffect hook since we should specify the number of rows in the API request. But since we need to ‘trigger’ the hook only when the active-page state changes, we specify only the active-page state in the dependency array.
This should work as we expect. However, the exhaustive-deps eslint rule should now throw a warning. This is because we haven’t passed the number-of-rows state into the dependency array. This may not make sense to us since we get the expected output with the current code. But what we are essentially doing here is abusing the useEffect hook to get what we want. So, what we have done is nothing but a hack and not a proper solution. While this might work here, this mental model and approach are guaranteed to produce buggy code.
The correct way of looking at the dependency array
So, what should be the correct mental model regarding the dependency array? Well, actually the dependency array does not offer any functional benefits. It is rather an optimization feature. As we saw earlier, the useEffect hook is used to synchronize changes to the states and props outside the React tree. The synchronization happens during every render. In other words, React calls the useEffect hook following every render.
However, this may not be necessary. If we take our example, it will be wasteful to send an API request following every render. The dependency array is used to address this. The dependency array gives React an idea about what states and props are used inside the useEffect hook so that React needs to run this hook only when these states and props change.
Consequently, we need to pass everything within the scope of the functional component, including states, props, functions, variables, and useRefs, that we use inside the useEffect into the dependency array. This tells React what we use inside the useEffect hook so that React can call the useEffect hook to synchronize the changes only when these dependencies change.
Going back to our example, we have to pass the active page state and the number-of-rows state into the dependency array since those are the only things we use inside the useEffect hook. So, React will call this hook to synchronize only when these two states change. This should fix the exhaustive-deps warning.
Two wrongs do not make a right
However, this will break the functionality of the component as the API request will now be dispatched both when the active-page state and the number-of-rows state change. But we want to send the API request only when the active page state changes. This is sure to give most developers an existential crisis. And this eventually forces some developers to consider disabling the offending eslint rule.
Nevertheless, we must understand that this conundrum is the direct result of writing a functional component while thinking in terms of class-based components. If we adopt the right mental model about the useEffect hook, then we should be able to accommodate different requirements the proper way. Let’s see how we can implement this with the right mental model.
Let’s now summarize the correct mental model for the useEffect hook.
The right mental model
The useEffect hook is used to synchronize changes to anything that is declared within the scope of the functional component such as states, props, functions, variables, and useRefs outside the React tree. We use the dependency array to tell React that we use those dependencies inside the useEffect hook so that React needs to call this hook only when these dependencies change. This is an optimization technic.
With this mental model, let’s see how we can address most of the common use cases in another article.