As many of you know that useCallback
is a built-in hook in React, usually used to scale and optimize large applications rendering performance by memoizing the functions to reduce recreating them in each re-render occurs. But you may not know how this hook actually works and how that is related to function closures, so this is what I'm gonna explain here!
⚠️ If you don't know why
useCallback
is used for, I recommend you to go through useCallback in React docs before you keep going in this blog.
useCallback
takes two parameters, a callback function and
an array of dependencies. In the first render useCallback
returns the passed function, and for subsequent renders it compares the old dependencies with the passed one using Object.is algorithm, If nothing has been changed in the dependencies will return the same old function, otherwise will return the passed function.
🔻See this simple skeleton for useCallback
🔻
const memoizedCallback = useCallback(
() => {
// function logic goes here
},
[dependencyArray]
);
🔻And this is a simulation for useCallback
as a normal JS function🔻
/*
Please note that this code is theoretical, this is not
how we actually compare two arrays together.
*/
let oldDependancies, oldCallBack; //React stores these somewhere
function useCallback(callback, dependencies) {
if(oldDependancies === dependencies) return oldCallBack;
oldDependancies = dependencies;
oldCallBack = callback;
return callback;
}
Theoretically the above code compares the old dependencies with the passed one, if the old one is the same as the passed one then will return the old callback function, otherwise it will assign the new dependencies array and the new callback to the old one, and returns the passed function.
Now lets dive into with a real example, the great Counter component 🤯
import React, { useState, useCallback } from "react";
function Counter() {
const [count, setCount] = useState(0);
const incrementCount = useCallback(() => {
setCount(count + 1);
}, [count]);
const decrementCount = useCallback(() => {
setCount(count - 1);
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={incrementCount}>Increment</button>
<button onClick={decrementCount}>Decrement</button>
</div>
);
}
This component renders a count number with increment and decrement buttons. Each of increment and decrement handlers memoized (cached) using useCallback
, and it's working as expected when click the buttons will increase and decrease the count by 1
. You can interact with it using the below box!
Problem
Let's change the above code a bit by removing the count
from the dependencies array in each useCallback
for incrementCount
and decrementCount
, to be like this.
const incrementCount = useCallback(() => {
setCount(count + 1);
}, []);
const decrementCount = useCallback(() => {
setCount(count - 1);
}, []);
Can you guess now what are the results when click increment
and decrement
buttons multiple times? check your answer using the below box
Now after you've clicked many times on the buttons, apparently the count won't increase more than 1 or decrease less than -1, and the reason behind that is the function closure.
Using useCallback
with an empty dependency array tills react to create the incrementCount
and decrementCount
functions only once, and not to recreate them on every render. Therefore, the closure of these functions contains the initial (stale) value of count, which is 0
.
When the incrementCount
function is called, it sets the value of count
to count + 1
, but count is still the old value of 0
in this closure, so the result is 1
. On subsequent calls to incrementCount
, the closure still contains the old value of count
, which is 0
, so the result is still 1
. And the same for decrementCount
the result will allways be -1
.
Solution
The solution for this issue is to add each variable affects the result to the dependency array, or by passing an updater function to setCount
instead of count +- 1
.
So incrementCount
will be like this
const incrementCount = useCallback(() => {
setCount(count + 1);
}, [count]);
OR
const incrementCount = useCallback(() => {
setCount((prevCount) => prevCount + 1);
}, []);
The reason is that updater function works with an empty dependency array, is this updater function is called during the rendering process and reads the actual value of the state at that time, to know more check queueing a series of state updates.
Conclusion
- Each variable affects the result of the function passed to
useCallback
should be added to the dependency array. - If the state change depends on the old state it's better pass an updater function to the state setter function.
- The memoized callback will has the same old closure until re-creating it because of dependency changes.
Thanks for reading!
Let me know if you have any thoughts by commenting below.
Top comments (2)
Very Informative Article!
Happy to hear that! Thanks!