react hooks and closure
I recently took over a project written in react hooks. I’ve found that if someone has been around react hooks long enough, he/she will encounter the following problems:
- A state never updates
- A method is constantly executing
I’m not saying you won’t run into the above problems with the Class Component. But when you compare the number of problems caused by these two approaches, and the difficulty of solving them, you’ll find that react hooks has more problems and is more difficult to solve.
I can write a lot if I want to, but this article is more about why there are so many problems while using react hooks.
The first thing to say is. The function component will be executed from top to bottom every time it is rendered.
It’s so self-evident that the official documentation doesn’t even mention it.
This concept is very important. With this concept in mind, you’ll be interested in the implementation of useState
. It’s a method that passes the same arguments each time, but returns different results.
And you’ll have a much better understanding of the problem ‘Why am I seeing stale props or state inside my function?’ than the official documentation.
The following code is from the official documentation.
function Example() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + count);
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
<button onClick={handleAlertClick}>
Show alert
</button>
</div>
);
}
The Example
function component will be executed from top to bottom due to the rendering process triggered by setCount
. The handleAlertClick
method is thus redefined once again.
If you know anything about closures, this handleAlertClick
is a closure, and the count
it references internally is also a closure.
You click Show alert
after the function component is executed (rendered). The value of count
displayed when alert
is executed 3 seconds later is the value at the moment you clicked Show alert
.
useEffects
is not that different from the normal functions or variables within function components.
Each time a function component is rendered, the useEffects
inside will be executed from top to bottom, but it depends whether the side effect
as its first argument will be executed after rendering.
As long as these side effects
are executed, they are executed as closures. This means that both state
and props
, which are internally referenced in side effect
, will remain as they were when the function component was last rendered.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${count} times`);
}, 3000);
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
In the above code, no matter how fast you click Click me
, console.log
will print out count
one by one, starting at 0.
This behavior has nothing to do with asynchronous execution, look at the following example without setTimeout
.
export default function App() {
const [count, setCount] = useState(0);
const [list, setList] = useState([]);
const increaseCount = () => {
const newCount = count;
console.log(newCount);
setCount(newCount + 1);
};
useEffect(() => {
const list = new Array(5).fill().map((_, index) => {
return (
<div key={index} onClick={increaseCount}>
try click me
</div>
);
});
setList(list);
}, []);
return (
<div className="App">
<div className="list">{list}</div>
<h2>{count}</h2>
</div>
);
}
All onClick
events are registered only at componentDidMount
, are not updated after that, and point to increaseCount
at that time. So no matter how many times you click try clike me
, count
is always 1, and the console always prints 0.
As a warm up, it’s easy to spot the problem with the above few examples. Take a look at the following.
export default function App() {
const [loading, setLoading] = useState(false);
const foo = () => {
console.log("is loading ? ", loading);
};
const bar = callback => {
setTimeout(() => {
setLoading(true);
callback();
}, 5000);
};
const runner = () => {
foo();
bar(runner);
};
useEffect(() => {
console.log("run");
runner();
}, []);
return (
<div className="App">
<h1>Is it loading ? {loading ? 'true' : 'false'}</h1>
</div>
);
}
The noteworthy thing about the above code is that the runner
method passes itself into bar
, where it is executed as a callback.
Run it here and you’ll see that the console keeps printing the value of loading
, but it’s always false
, which is inconsistent with what’s shown on the page.
Obviously, the re-rendering is triggered by setLoading(true)
, but why is the value of loading
in the console always false
?
The reason for this is that the second and subsequent calls of runner
are in the form of callback
.
This callback
points to the runner
that comes after the initial rendering, the foo
it contains, and the loading
of foo
all maintain their initial rendering values.
Since the value of loading
is false
after the initial rendering, the console’s loading
is always false
.
Incidentally, if you remove the second parameter []
from useEffect
above, you will see that the console prints out two values of loading
every 5 seconds, which are false
and true
respectively.
As smart as you are, you’ll be able to figure out why, and I won’t reveal the answer.
Reference