18
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();
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
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);
};
}
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
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);
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....
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();
To break it down,
- There are two variables
count
andmessage
in our App. - We are returning two functions
increment
andlog
. - As per the name,
increment
increases ourcount
andlog
simply logs themessage
.
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
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();
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
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();
And executing would produce the result,
1
2
3
Count 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?
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>
);
}
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>
);
}
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?
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.
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:
18