React Hooks Dependencies and Stale Closures
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}78let print = App(1);9print();10print();11print();1213print = App(5);14print();15print();
The above function is a simple example of closure in JavaScript. The console output is as below.
1Counter initialized with 12233445Counter initialized with 56677
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}78let 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() {23 let count = 0;45 function increment() {6 count = count + 1;7 }89 let message = `Count is ${count}`;1011 function log() {12 console.log(message);13 }1415 return [increment, log];16}1718let [increment, log] = App();19increment();20increment();21increment();22log();
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
1function App() {23 let count = 0;45 function increment() {6 count = count + 1;7 console.log(count);8 }910 let message = `Count is ${count}`;1112 function log() {13 console.log(message);14 }1516 return [increment, log];17}1819let [increment, log] = App();20increment();21increment();22increment();23log();
And this time, the output will be
123Count 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() {23 let count = 0;45 function increment() {6 count = count + 1;7 console.log(count);8 }910 function log() {11 let message = `Count is ${count}`;12 console.log(message);13 }1415 return [increment, log];16}1718let [increment, log] = App();19increment();20increment();21increment();22log();
And executing would produce the result,
1122334Count 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);34 let message = `Count is ${count}`;56 React.useEffect(() => {7 if (count === 3) {8 console.log(message);9 }10 }, []);1112 return (13 <div className="App">14 <h1>{count}</h1>15 <button16 onClick={() => {17 setCount((c) => c + 1);18 }}19 >20 Increment21 </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);34 let message = `Count is ${count}`;56 React.useEffect(() => {7 if (count === 3) {8 console.log(message);9 }10 }, [count, message]);1112 return (13 <div className="App">14 <h1>{count}</h1>15 <button16 onClick={() => {17 setCount((c) => c + 1);18 }}19 >20 Increment21 </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.
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