DEV Community

Cover image for React Hooks Dependencies and Stale Closures
payapula
payapula

Posted on • Originally published at bharathikannan.com

React Hooks Dependencies and Stale Closures

After we got the confidence on the flow of hooks in React, it's important to understand about it's dependencies as well.

In this post we will dive a bit deeper into the dependency array of hooks.

As always, let's start with a Javascript example. Before looking at the output, try to guess what would be logged.

function App(count) {
  console.log('Counter initialized with ' + count);
  return function print() {
    console.log(++count);
  };
}

let print = App(1);
print();
print();
print();

print = App(5);
print();
print();
Enter fullscreen mode Exit fullscreen mode

The above function is a simple example of closure in JavaScript. The console output is as below.

Counter initialized with 1 
2
3
4
Counter initialized with 5 
6
7
Enter fullscreen mode Exit fullscreen mode

If you can get it, then great! I will go ahead and explain what is happening.

The App function returns another function called print this makes our App, a higher order function.

Any function that returns another function or that takes a function as argument is called as Higher order function.

function App(count) {
  console.log('Counter initialized with ' + count);
  return function print() {
    console.log(++count);
  };
}
Enter fullscreen mode Exit fullscreen mode

The retuned function print closes over the variable count which is from its outer scope. This closing is referred to as closure.

Please don't get confused with the name of the functions. Names need not necessarily be identical, as for an example

function App(count) {
  console.log('Counter initialized with ' + count);
  return function increment() {
    console.log(++count);
  };
}

let someRandomName = App(1);
someRandomName(); //logs 2
Enter fullscreen mode Exit fullscreen mode

Here App is returning a function increment and we are assigning it to the variable someRandomName

To define a "Closure",

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function’s scope from an inner function. ~ MDN

Ah? that doesn't look like simple definition right ?

Alright, MDN is not much helpful here let us see what W3Schools says

A closure is a function having access to the parent scope, even after the parent function has closed. ~ W3Schools

When we call the App function, we get the print function in return.

let print = App(1);
Enter fullscreen mode Exit fullscreen mode

The App function gets count as 1 and returns print which simply increases the count and logs it. So each time when print is called, the count is incremented and printed.

If we are writing logic that uses closures and not careful enough, then we may fall into a pitfall called....

Stale Closures

To understand what is stale closures, let us take our same example and modify it further.

Take a look at this code and guess what would be getting logged into the console.

function App() {

  let count = 0;

  function increment() {
    count = count + 1;
  }

  let message = `Count is ${count}`;

  function log() {
    console.log(message);
  }

  return [increment, log];
}

let [increment, log] = App();
increment();
increment();
increment();
log();
Enter fullscreen mode Exit fullscreen mode

To break it down,

  1. There are two variables count and message in our App.
  2. We are returning two functions increment and log.
  3. As per the name, increment increases our count and log simply logs the message.

Try to guess the output. Let me give you some space to think.
.
.
.
.
.
.
.
.
Warning! 🚨 Spoilers 🚨 ahead
.
.
.
.
.
.
.
.

The output is

Count is 0
Enter fullscreen mode Exit fullscreen mode

Oh, did we fail to increment the count?

Let's find out by placing console log inside our increment function

function App() {

  let count = 0;

  function increment() {
    count = count + 1;
    console.log(count);
  }

  let message = `Count is ${count}`;

  function log() {
    console.log(message);
  }

  return [increment, log];
}

let [increment, log] = App();
increment();
increment();
increment();
log();
Enter fullscreen mode Exit fullscreen mode

And this time, the output will be

1
2
3
Count is 0
Enter fullscreen mode Exit fullscreen mode

Yes, we are incrementing the count that is present in the lexical scope of increment. However, the problem is with the message and log.

Our log function captured the message variable and kept it. So, when we increment the count, the message is not updated and our log returns the message "Count is 0".

To fix this stale closure, we can move the message inside of log

function App() {

  let count = 0;

  function increment() {
    count = count + 1;
    console.log(count);
  }

  function log() {
    let message = `Count is ${count}`;
    console.log(message);
  }

  return [increment, log];
}

let [increment, log] = App();
increment();
increment();
increment();
log();
Enter fullscreen mode Exit fullscreen mode

And executing would produce the result,

1
2
3
Count is 3 
Enter fullscreen mode Exit fullscreen mode

As per the name, stale closure is when we fail to capture updated value from the outer scope, and getting the staled value.

Hmm.. So, what does this stale closure has to do in React?

Hooks are nothing but Closures!

Let us bring the same JS example we saw above, into the react world,

function App() {
  const [count, setCount] = React.useState(0);

  let message = `Count is ${count}`;

  React.useEffect(() => {
    if (count === 3) {
      console.log(message);
    }
  }, []);

  return (
    <div className="App">
      <h1>{count}</h1>
      <button
        onClick={() => {
          setCount((c) => c + 1);
        }}
      >
        Increment
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

After hitting Increment button three times, we should have a log that says "Count is 3".

Sadly we don't event get anything logged !!!

This is however not exact replica of our example from our JS world, the key difference is in our React world, message does get updated, but our useEffect just failed to capture the updated message.

To fix this stale closure problem, we need to specify both count and message as our dependency array.

function App() {
  const [count, setCount] = React.useState(0);

  let message = `Count is ${count}`;

  React.useEffect(() => {
    if (count === 3) {
      console.log(message);
    }
  }, [count, message]);

  return (
    <div className="App">
      <h1>{count}</h1>
      <button
        onClick={() => {
          setCount((c) => c + 1);
        }}
      >
        Increment
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note - This is just a contrived example, You may choose to ignore either of those dependencies as both are related. If count is updated, message does get updated, so specifying just either of those is fine to get the expected output.

Things are simple with our example, The logic that we wrote inside the hook is not really a side effect, but it will get more and more complicated if we start to write hooks for data fetching logic and other real side effects

The one thing that, always we need to make sure is,

All of our dependencies for hooks must be specified in the dependency array and, we should not lie to React about dependencies

As I said, things get really complicated with closures in real-world applications and it is so very easy to miss a dependency in our hooks.

From my experience, if we failed to specify a dependency and if not caught during the testing, later it would eventually cause a bug and in order to fix it we may need to re-write the entire logic from scratch !!

This is a big 🚫 NO 🚫 and MUST BE AVOIDED at all cost. But how?

ESLint Plugin React Hooks

In order to make our life simpler, the react team wrote an ESLint Plugin called eslint-plugin-react-hooks to capture all possible errors with the usage of hooks.

So when you are all set up with this eslint plugin react hooks When you miss a dependency, it would warn you about the possible consequence.

If you are using latest create-react-app then this comes out of the box (react-scripts >= 3.0)

As seen below, when we violate the rules of hooks we will get a nice warning suggesting that we are probably doing something wrong.

ESLint Warning on usage of hooks

The above image shows the error from ESLint that reads, React Hook React.useEffect has missing dependencies: 'count' and 'message'. Either include them or remove the dependency array.

It even fixes the dependency problem with just a single click!

Keep in mind that a stale closure problem does not only affect useEffect, we would run into the same problem with other hooks as well like useMemo and useCallback.

The Eslint plugin works with all the React hooks, can also be configured to run on custom hooks. Apart from just alerting with dependency issues, it would also check for all the rules of hooks, So, make good use of it!

Again to enforce,

🚫 Don't Lie to React about Dependencies and 🚫 Don't disable this ESLint rule 🤷🏾‍♂️


Big Thanks to:

Oldest comments (3)

Collapse
 
lexlohr profile image
Alex Lohr

It's not a lie if you only want to use a dependency, but not listen on its changes. You could then either use a ref (and maybe need to keep it updated) or tell react that your effect does not depend on it.

Collapse
 
payapula profile image
payapula

Thanks for the comments 🤝

Yes a ref could be used to remove mentioning it in deps array, but if you use some variable inside a hook and tell to react that it doesn’t depend on it (even if it’s true) then probably in the future, when some other dev try to refactor the code they may run into the stale closure problem which is not very easy to find.

I think It’s not a bad idea to put that in a dependency array even if we know that it won’t change. If it changes and we are not using the fresh value inside the hook then we probably want to simplify our hook.

Dan has written an excellent article covering the exact use case of lying to react about dependencies. - Don’t Lie to react

Collapse
 
lexlohr profile image
Alex Lohr

The use case here is if you only depend on the value of the variable at the time the effect runs.

This can actually be a performance benefit, as you do not render more often than necessary.

Otherwise, you should be aware that you are creating a potential tripwire.