DEV Community

Cover image for Why Effects shouldn't lie about their dependencies
Amandeep Singh
Amandeep Singh

Posted on

Why Effects shouldn't lie about their dependencies

For a long time React developers have been inventing and sharing different patterns of reusing code logic — Higher order component and render props are to name a few. It was because React had no stateful primitive simpler than class component. Finally, the advent of hooks into the React ecosystem has uprooted this problem and made code reusability a breeze. If you are interested to know why hooks were introduced, I have a written a separate in-depth article about it here.

In this article, I am going to talk about useEffect hook only and will share some of my learnings and few caveats associated with them. A few things we will discuss are:

  1. We will start off with an example of a using useEffect, which has a bug.
  2. Then, we will try to demystify the cause of this bug 😀.
  3. And finally, we’ll see how can we avoid these bugs and write effects which are easy to reason about.

Before we go ahead, I’d like you to unlearn what you have been doing with the class component’s lifecycles. With hooks, we need a different mindset.

In hooks land, functions are the kings.

Enough of the background. Let’s get started now.

A recap

Side effects are an indispensable part of any web applications. Fetching data, manually mutating DOM, and setting up subscription are all examples of side effects. The useEffect hook lets you perform side effects in your function component.

 // Inside your function component 
 useEffect(() => {
 // some side effect code 
 });
}

I have seen some of the developers assuming that it’s always the same effect (anonymous function) which React calls after every render. But this is not the case.
Every time a re-render happens, we schedule a new effect replacing the previous effect. This is intentional and important as it makes the effect behaves more like a part of the render result. The key point to remember here is that each effect “belongs” to a particular render.

Each effect “belongs” to a particular render.

There is also an optional second argument to useEffect call — the dependency array. This is a mechanism by which React knows when to skip running your effect if certain values haven’t changed between re-renders.

In the cases where effects require cleanup, we can optionally return a cleanup function. Keep in mind that React always calls this cleanup function before applying the next effect.

React always clean up the previous effect before applying the next effect.

With basics aside, let’s now move onto the fun part.

1. A buggy effect

Here is an example code snippet demonstrating the usage of setInterval (a side effect) inside useEffect hook:

function CounterWithBug() {
  const [count, setCount] = useState(0);
useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }, []);
return <h1>Count is {count} </h1>;
}

Just by looking at this code, can you identify any bug?

This code may look perfectly fine but our count value doesn’t increment. Here’s the demo link if you wish to see that in action. You might be thinking that setInterval callback is calling the setter which should increment the count value after every 1 second. But this is not happening. What are we missing?

2. Demystifying the cause of the bug

We can definitely fix this issue with one small change and I’m sure most of you know how. But let’s take a step back and try to understand why this behaviour exists.

Every time when callback inside the setInterval calls the setter, React does a re-render. Doing so creates a new effect (function). But interestingly, as we have passed an empty dependency array [], which is a signal to React to skip applying this effect after the first render, it never gets invoked the second time.
Now you might be wondering how does it make a difference: our setter is being called every time and so it should increment the count value. Right?

This behaviour has nothing to do with React. It’s about how closures work in JavaScript. In simple words, all functions in ECMAScript are closures since all of them at creation stage lexically captured the scope chain of itself and parent context. This is regardless of whether a function is activated later or not.

Let’s consider an example:

let x = 10;
// function is created here (not invoked yet)
function bar() {
  console.log(x);
}
function foo() {
  let x = 50;
  bar(); // invocation happens here
}
foo(); // will print 10

When foo is invoked, 10 will be printed, but not 50. This is because when the bar is created earlier (function creation stage), x is stored statically into its scope chain and that’s what gets resolved when bar execution is activated later on.

Let’s consider one more example to strengthen our closure concept.

function parent() {
  let x = 20;
  setTimeout(() => console.log(x), 1000);
}
parent(); // prints 20 after a minimun time delay of 1 sec.

Even though the parent execution context is destroyed, callback inside the interval still manages to print the correct value of x after 1 second delay. This happens because of the closure. The inner function, statically at creation time, captures the variables defined in the parent scope.

The inner function, statically at creation time, captures the variables defined in the parent scope.

If you want to delve more into the concept of closures, I’ve written an article about it here too.

Now taking this new knowledge along, let’s visit our effect one more time from a different angle. Here’s the snippet again so that you don’t have to scroll up:

function CounterWithBug() {
  const [count, setCount] = useState(0);
useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }, []); // 🛑 missing the 'count' dependency
return <h1>Count is {count} </h1>;
}

When the effect is executed after the first render, the anonymous callback inside setInterval statically captures the count value from its parent context. This happens at the creation stage and the value captured is 0. After a minimum delay of 1 sec, this callback is invoked, which in turn calls the setter with a new value of 1 (0 + 1). In response to this, React re-renders the component and you get to see the new count value of 1 in the UI.

Now, as dependency array is empty, React will only create a new effect replacing the previous one, but never runs it. And as we just learned that React always cleans up the previous effects before applying the next effects, it will not bother running the cleanup in this case. Consequently, the initial interval never gets cleared out and our anonymous callback is still holding on to the count value of 0 into its scope chain. When the setter is called, the new value passed to it is always 1 (0 + 1). This is why the count value doesn’t increment beyond 1.

3. Never lie about your effect’s dependencies — a few fixes

After successfully unveiling the root cause of the bug, now is the time to fix it. It’s always easy to find a cure when you know the exact source of the problem. The problem was that the interval captured the count value of 0 statically when the first render happened. So, the solution is to make the interval captured the latest count value every render. How can we make that possible? Can we take help from React?

Yes! you guessed it right — the dependency array. Whenever the value inside the dependency array changes, React cleans up the previous effect and applies the new one.

Fix 1: using ‘count’ as a dependency

In our buggy code example, we just need to pass the count variable as a dependency to fix the issue. Here’s the demo link.

function Counter() {
  const [count, setCount] = useState(0);
useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }, [count]); // ✅ passing 'count' as dependency
  // will render the correct value of count
return <h1>Count is {count} </h1>;
}

Now with this little change, whenever the count value changes, React goes ahead and first call our cleanup mechanism which clears up the previous interval, and then sets a new interval by running the effect again. Bingo!! 🎉

In our code, the effect has a dependency over the count variable. So, it should also be inside the dependency array.

So, the lesson is here that an effect should always be honest about its dependency. Every time this promise fails, a buggy code behaviour may appear.

Effect should never lie about its dependency.

Fix 2: completely removing the dependency array

Another fix to resolve this issue is to completely remove the dependency array. When there’s no dependency array, React will make sure to follow the routine of clearing up the previous effect before running the new one. And now, of course, you know why does it make a difference 😀

function Counter() {
  const [count, setCount] = useState(0);
// the following effect will run after the first render and after each update
  useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }); // ✅ No dependency array here.
  // will render the correct value of count
return <h1>Count is {count} </h1>;
}

Here’s the demo in action.

Fix 3: using ‘updater’ function inside the setter

Now, if you have a sharp eye, you might have noticed that both aforementioned fixes are not very efficient. We are creating a new interval for each render. Our counter may run slowly as the browser has to clear up the previous interval before applying the new one. This could take some microseconds which could slowly add up and our counter would start to feel slow.

So, can we just set our interval once and only clear it when our component unmounts? The only way to do this is by passing an empty array. Right? But then we are again hitting the same issue as we saw above. We have to pass the count variable again.

Well, to solve this conundrum, we will follow the same rule of thumb — don’t lie about your effect’s dependency. Check out the demo here.

function Counter() {
  const [count, setCount] = useState(0);
useEffect(() => {
    // ✅ No more dependency on `count` variable outside
    const id = setInterval(() => setCount(c => c + 1), 1000);
    return () => clearInterval(id);
  }, []);
return <h1>Count is : {count}</h1>;
}

Here we are using the updater function inside our setter function which doesn’t depend on count variable outside. Doing so, allow us to use an empty dependency array. We are not lying to React about our effect’s dependency. This is a moment of proud 👏.

Fix 4: “useRef” to the rescue

Before wrapping it up, I want to show you one more solution to this issue. This solution is based around using another hook called useRef.

I don’t want to go into much details of explaining how useRef works. But I think of them as a box where you can place any value. They are more like instance properties in JavaScript classes. The interesting fact is React preserves the value of a returned object from useRef across different renders.

Let’s visit our code example again the last time:

function CounterUsingRef() {
  const [count, setCount] = useState(0);
// ✅ putting fresh count into the latestCount
  const latestCount = useRef();
useEffect(() => {
    // ✅ make sure current always point to fresh value of count
    latestCount.current = count;
  });
useEffect(() => {
    const id = setInterval(() => setCount(latestCount.current + 1), 1000);
    return () => clearInterval(id);
  }, []);
return <h3>Counter with useRef: {count}</h3>;
}

Again we’ve kept our promise of not lying about our dependency. Our effect is no more count variable dependent.

Even though the interval is still statically capturing the latestCount object (as it does in the case of the first buggy example), React makes sure the mutable current always gets the fresh count value. 🙂

Here’s the demo for above code snippet if you’re interested.

Conclusion

Let’s recap what we have just learned:

  1. The function passed to useEffect is going to be different on every render and this behaviour is intentional.
  2. Every time we re-render, we schedule a new effect, replacing the previous one.
  3. All functions, at the creation stage, statically captures the variable defined in the parent scope.
  4. We should never lie to React about our effect’s dependencies.

I hope this article was interesting to read and has helped you understand why dependency array plays an important role in our effects. Consequently, I strongly recommend installing an ESLint plugin called eslint-plugin-react-hook that enforces this rule.

Here’s a single link of all the demos combined in one file. Keep an eye on the second fix and see how it is slower 🐢 than the last two fixes.

Also, Let me know your thoughts in the comments below and if you liked it, a few 👏 will definitely make me smile 😃. Now go ahead and share this knowledge with others.

Top comments (4)

Collapse
 
dance2die profile image
Sung M. Kim

Thank you for detailed explanations on how useEffect deps work and how not to lie about it 😀

I have a question regarding 2. Demystifying the cause of the bug,

React always cleans up the previous effects before applying the next effects, it will not bother running the cleanup in this case

I just tried the code snippet and the clean up occurs.

demo

Demo code above

What would you mean by the clean up never running?

Collapse
 
aman_singh profile image
Amandeep Singh • Edited

Hi,
In your code snippet, you are setting alternate key (true and false) on every click handler to the div element. key plays an important role in React reconciliation process. Setting a different key on your div element is like asking React to recreate the whole tree, which follows the process of unmounting the component and remounting it. And doing so, it runs the cleanup effect as it will do in the normal umount cycle.
Hope it helps 🙂

Collapse
 
dance2die profile image
Sung M. Kim

Thanks Amandeep for the explanation, links (and the nice word highlights) 😀

Thread Thread
 
aman_singh profile image
Amandeep Singh

Thanks Sung. Happy that it was helpful. 😀