Recently I found a construction like this while doing code review:
const MyComponent = (props) => {
const [isToggled, setIsToggled] = React.useState(false);
const toggle = React.useCallback(() => setIsToggled(!isToggled));
return ...;
};
Creating a boolean state and a toggle method for it is a pretty common use case. The spinnet is 100% correct in terms of functionality. But it could be better in terms of performance. Let's see how it can be improved.
So what's wrong?
First things first β useCallback
does nothing in this implementation. Unless a dependencies array is passed as a second parameter, useCallback
is not keeping the same reference to a callback through renders and is equal to the following callback declaration:
const toggle = () => setIsToggled(!isToggled);
Also, current implementation violates the exhaustive dependencies rule: every value referenced inside the function should also appear in the dependencies array. This is required to ensure that values inside a callback are always up-to-date and avoid any bugs related to that.
Let's see in practice how these two impact performance. First, let's create a simple RendersCounter
component that takes a single onClick
prop. It's going to count how many times a component was rendered:
import React from 'react';
const RendersCounter = ({ onClick }) => {
const rendersCountRef = React.useRef(0);
rendersCountRef.current += 1;
return (
<div>
<span>
RendersCounter rendered <b>{rendersCountRef.current}</b> time(s)
</span>
<button style={{ marginLeft: '10px' }} onClick={onClick}>
toggle
</button>
</div>
)
};
export default React.memo(RendersCounter);
Note that RendersCounter
is wrapped with React.memo
. The optimizations we're going to make are only working if a child component is a pure component: it's an instance of React.PureComponent
, a functional component wrapped with React.memo
, or has referential equality render optimization via shouldComponentUpdate
or any other way to do it. If you don't have any of those implemented for a child component, it will be re-rendered every time a parent component is re-rendered regardless of the way you implement a callback.
Now let's use this component to see what happens if we don't pass dependencies to useCallback
at all. I will create two separate state handlers: one for our boolean state and another one for storing a random number.
const BasicBooleanState = () => {
const [isToggled, setIsToggled] = React.useState(false);
const toggle = React.useCallback(() => setIsToggled(!isToggled));
const [randomNumber, setRandomNumber] = React.useState(Math.random());
const generateRandomNumber = React.useCallback(
() => setRandomNumber(Math.random()),
[],
);
return (
<div>
<div>
Current random number is <b>{randomNumber}</b>
<button style={{ marginLeft: '10px' }} onClick={generateRandomNumber}>
regenerate
</button>
</div>
<div>
Boolean is set to <b>{String(isToggled)}</b>.
</div>
<RendersCounter onClick={toggle} />
</div>
);
}
RendersCounter
re-renders even though the boolean state is not changed at all!
As said before, the current toggle
implementation with useCallback
is equal to a regular arrow function declaration. It's re-created each render so RendersCounter
gets a referentially different onClick
prop that causes its re-render when it doesn't have to.
Fixing missing dependencies
React documentation says:
Every value referenced inside the function should also appear in the dependencies array
If you don't follow this rule you might end up having outdated values inside a callback. There are two external values used inside the toggle
callback: isToggled
and setIsToggled
. Let's put them into the useCallback
's dependencies array.
const BasicBooleanState = () => {
const [isToggled, setIsToggled] = React.useState(false);
// here we added [isToggled, setIsToggled] as a second parameter
const toggle = React.useCallback(
() => setIsToggled(!isToggled),
[isToggled, setIsToggled],
);
const [randomNumber, setRandomNumber] = React.useState(Math.random());
const generateRandomNumber = React.useCallback(
() => setRandomNumber(Math.random()),
[],
);
return (
<div>
<div>
Current random number is <b>{randomNumber}</b>
<button style={{ marginLeft: '10px' }} onClick={generateRandomNumber}>
regenerate
</button>
</div>
<div>
Boolean is set to <b>{String(isToggled)}</b>.
</div>
<RendersCounter onClick={toggle} />
</div>
);
}
Now RendersCounter
is not re-rendering when a random number changes! We said our callback to update only when isToggled
or setIsToggled
change so it's referentially equal unless isToggled
changes.
But when we toggle the boolean state from the RendersCounter
it gets re-rendered. And this makes sense because isToggled
changes and it's a part the useCallback
's dependencies array.
Optimizing a callback
To fix the problem of re-creating the toggle
callback we need a way to avoid depending on isToggled
directly but still have its actual value inside a callback. Here's what useRef
can help with. We just need to create a reference once and update its value when isToggled
changes. Then we replace isToggled
with the reference in the dependencies array and callback itself and that's it!
Let's create a custom hook that would return a current boolean state and a toggle method that is changing a boolean value and never gets re-created
// it might be a project-level reusable hook
const useToggle = (initialState) => {
const [isToggled, setIsToggled] = React.useState(initialState);
const isToggledRef = React.useRef(isToggled);
// put [isToggledRef, setIsToggled] into the useCallback's dependencies array
// these values never change so the calllback is not going to be ever re-created
const toggle = React.useCallback(
() => setIsToggled(!isToggledRef.current),
[isToggledRef, setIsToggled],
);
// keep the value in isToggledRef actual
// when isToggled changes, isToggledRef is updated accordingly
React.useEffect(
() => {
isToggledRef.current = isToggled;
},
[isToggled],
);
return [isToggled, toggle];
}
Instead of isToggled
we use isToggledRef
to create the toggle
callback. Both isToggledRef
and setIsToggled
are created only once and React ensures they never change and are referentially equal through renders. That means there's no reason for the toggle
callback to be ever re-created.
To make sure the value in isToggledRef
is up-to-date we use useEffect
with a single isToggled
dependency in the dependencies array. It will be executed only when isToggled
changes.
It's time to use the hook we created:
const OptimizedBooleanState = () => {
const [isToggled, toggle] = useToggle(false);
const [randomNumber, setRandomNumber] = React.useState(Math.random());
const generateRandomNumber = React.useCallback(
() => setRandomNumber(Math.random()),
[],
);
return (
<div>
<div>
Current random number is <b>{randomNumber}</b>
<button style={{ marginLeft: '10px' }} onClick={generateRandomNumber}>
regenerate
</button>
</div>
<div>
Boolean is set to <b>{String(isToggled)}</b>.
</div>
<RendersCounter onClick={toggle} />
</div>
);
}
Now RenderCounter
never gets re-rendered!
Update
As Juan Gabriel S. Palarpalar mentioned in comments, there's no need to use refs in this case. The desirable behavior can be achieved with a functional state updater. Instead of passing a value to setIsToggled
we need to pass a function that takes the current state as the first argument. This really makes the hook way clearer:
setIsToggled(state => !state);
Here's how updated useToggle
hook looks:
const useToggle = (initialState) => {
const [isToggled, setIsToggled] = React.useState(initialState);
// put [setIsToggled] into the useCallback's dependencies array
// this value never changes so the callback is not going to be ever re-created
const toggle = React.useCallback(
() => setIsToggled(state => !state),
[setIsToggled],
);
return [isToggled, toggle];
}
Conclusion
At the end of the day, useCallback
is only about optimization. Your code will still work properly if you declare a callback as a plain arrow function so it's up to you to find a balance between optimization and code brevity.
React Hooks API is super powerful. It allows you to write clear declarative code. It can also boost your app's performance if cooked right.
Thank you for reading!
Top comments (6)
a note: eslint react hook rules don't complain about a state setter created by
setState
as a missing dependency as it never changes. So while it doesn't hurt, an empty dependencies array would work just fine in this case.Sounds good. I would put anything I use into dependencies for consistency but itβs a good note for those who feels better when the dependencies array is empty. Thank you!
You can provide a callback for
useState
that accepts the current state.setIsToggled(state => !state)
I don't get why you have to use refs for this.
You're 100% right, there's no need to mess with refs in this case. I totally forgot about the functional state update. I'm going to add a note about that. Thank you!
just perfect!
Hey, I'm happy that you liked it!