React Hooks Dependencies and Stale Closures

Wheel and gears of bicycle

Photo By Wayne Bishop

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

1function App(count) {
2 console.log('Counter initialized with ' + count);
3 return function print() {
4 console.log(++count);
5 };
6}
7
8let print = App(1);
9print();
10print();
11print();
12
13print = App(5);
14print();
15print();

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

1Counter initialized with 1
22
33
44
5Counter initialized with 5
66
77

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.

1function App(count) {
2 console.log('Counter initialized with ' + count);
3 return function print() {
4 console.log(++count);
5 };
6}

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

1function App(count) {
2 console.log('Counter initialized with ' + count);
3 return function increment() {
4 console.log(++count);
5 };
6}
7
8let someRandomName = App(1);
9someRandomName(); //logs 2

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.

1let print = App(1);

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.

1function App() {
2
3 let count = 0;
4
5 function increment() {
6 count = count + 1;
7 }
8
9 let message = `Count is ${count}`;
10
11 function log() {
12 console.log(message);
13 }
14
15 return [increment, log];
16}
17
18let [increment, log] = App();
19increment();
20increment();
21increment();
22log();

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

Oh, did we fail to increment the count?

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

1function App() {
2
3 let count = 0;
4
5 function increment() {
6 count = count + 1;
7 console.log(count);
8 }
9
10 let message = `Count is ${count}`;
11
12 function log() {
13 console.log(message);
14 }
15
16 return [increment, log];
17}
18
19let [increment, log] = App();
20increment();
21increment();
22increment();
23log();

And this time, the output will be

1
2
3
Count is 0

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

1function App() {
2
3 let count = 0;
4
5 function increment() {
6 count = count + 1;
7 console.log(count);
8 }
9
10 function log() {
11 let message = `Count is ${count}`;
12 console.log(message);
13 }
14
15 return [increment, log];
16}
17
18let [increment, log] = App();
19increment();
20increment();
21increment();
22log();

And executing would produce the result,

11
22
33
4Count is 3

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 into the react world,

1function App() {
2 const [count, setCount] = React.useState(0);
3
4 let message = `Count is ${count}`;
5
6 React.useEffect(() => {
7 if (count === 3) {
8 console.log(message);
9 }
10 }, []);
11
12 return (
13 <div className="App">
14 <h1>{count}</h1>
15 <button
16 onClick={() => {
17 setCount((c) => c + 1);
18 }}
19 >
20 Increment
21 </button>
22 </div>
23 );
24}

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.

1function App() {
2 const [count, setCount] = React.useState(0);
3
4 let message = `Count is ${count}`;
5
6 React.useEffect(() => {
7 if (count === 3) {
8 console.log(message);
9 }
10 }, [count, message]);
11
12 return (
13 <div className="App">
14 <h1>{count}</h1>
15 <button
16 onClick={() => {
17 setCount((c) => c + 1);
18 }}
19 >
20 Increment
21 </button>
22 </div>
23 );
24}

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 the eslint plugin 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 Plugin Shows Warning

The above GIF 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:


Published:May 9, 2021

Updated:August 8, 2022