This example perfectly summarizes the downsides of useCallback
. Not only did we duplicate all the props we used in a closure, but also consider what happens when we update the password field:
HeavyInput
triggers setFormValue({ password: '123', username: '' })
formValue
reference updatesFormItem
s re-render, which is fair enoughonChange
in username FormItem
updates, too, since value reference updatedHeavyInput
in username FormItem
re-renders, because FormItem
's onChange
has a new referenceThis may be OK with 2 fields, but what about a hundred? What about when your callback has so many dependencies something updates on every render? You might argue that the components should have been modeled some other way, but there is nothing conceptually wrong with this one that can't be fixed with a better useCallback
.
Back with class components we had no hooks, but changes in callback prop reference did trigger useless child component update, just as it does now (hence react/jsx-no-bind
eslint rule). The solution was simple: you create a class method (or, lately, into a property initializer) to wrap all the props
references you need, and pass this method as a prop instead of an arrow:
class FormItem extends Component {
onChange = (e) => this.props.onChange({ ...this.props.value, [this.props.name]: e.target.value });
render() {
return <HeavyInput onChange={this.onChange} />
}
}
onChange
method is created in constructor and has a stable reference throughout the lifetime of the class, yet accesses fresh props when called. What if we just applied this same technique, just without the class?
So, without further adue, let me show you an improved useCallback
:
const useStableCallback = (callback) => {
const onChangeInner = useRef();
onChangeInner.current = callback;
const stable = useCallback((...args) => onChangeInner.current(...args), []);
return stable;
};
Watch closely:
onChangeInner
is a box that always holds the fresh value of our callback
, with all the scope it has.callback
is thrown away on each render, so I'm pretty sure it does not leak.stable
is a callback that never changes and only references onChangeInner
, which is a stable box.Now we can just swap useCallback
for useStableCallback
in our working example. The dependency array, [onChange, name, value]
, can be safely removed — we don't need it any more. The unnecessary re-renders of HeavyInput
magically disappear. Life is wonderful once again.
There is one problem left: this breaks in concurrent mode!
While React's concurrent mode is still experimental and this code is completely safe when used outside it, it's good to be future-proff when you can. A concurrent-mode call to render function does not guarantee the DOM will update right away, so by changing the value of onChangeInner.current
we're essentially making future props
available to the currently mounted DOM, which may give you surprising and unpleasant bugs.
Following in the footsteps of an exciting github issue in react repo, we can fix this:
const useStableCallback = (callback) => {
const onChangeInner = useRef(callback);
// Added useLayoutEffect here
useLayoutEffect(() => {
onChangeInner.current = callback;
});
const stable = useCallback((...args) => onChangeInner.current(...args), []);
return stable;
};
The only thing we've changed was wrapping the update of onChangeInner
in a useLayoutEffect
. This way, the callback will update immediately after the DOM has been updated, fixing our problem. Also note that useEffect
would not cut it — since it's not called right away, the user might get a shot at calling a stale callback.
One drawback of this solution is that now we can't use the function returned inside the render function since it has not been updated yet. Specifically:
const logValue = useStableCallback(() => console.log(props.value));
// will log previous value
logValue();
return <button onClick={logValue}>What is the value?</button>
We don't need a stable function reference to call it during render, so that works for me.
When compared to React's default useCallback
, our proposal with a totally stable output:
setTimeout
or as a native event listener.At a cost of not being able to call it during render. For me, this sounds like a fair deal.