useCallback
has always been one of my least favorite hooks:
- it does not provide much value over
useMemo
(as we learnt in my previous post on hooks), - it weirdly treats function as derived data, recreating it on dependency changes, a pattern I haven’t seen anywhere else
- it requires you to list the variables you reference within a closure, which is boring and flaky, and relies on imperfect static analysis to enforce this.
Luckily, we can build a better useCallback
ourselves using nothing but useRef
and our JS ninja skills.
A working example
function FormItem({ name, value, onChange, ...props }) {
const onChange = useCallback(e => {
onChange({ ...value, [name]: e.target.value });
}, [onChange, name, value]);
return <HeavyInput onChange={onChange} value={value[name]} {...props} />;
};
function LoginForm() {
const [formValue, setFormValue] = useState({
username: '',
password: '',
});
return (<>
<FormItem name="password" value={formValue} onChange={setFormValue} />
<FormItem name="username" value={formValue} onChange={setFormValue} />
</>);
}
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:
- Password
HeavyInput
triggerssetFormValue({ password: '123', username: '' })
-
formValue
reference updates -
Both
FormItem
s re-render, which is fair enough -
onChange
in usernameFormItem
updates, too, since value reference updated -
HeavyInput
in usernameFormItem
re-renders, becauseFormItem
‘sonChange
has a new reference
This 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
.
The classic solution
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?
The proposal
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 ourcallback
, with all the scope it has. - Old
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 referencesonChangeInner
, 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!
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.
Wrapping up
When compared to React’s default useCallback
, our proposal with a totally stable output:
- Simplifies the code by removing explicit dependency listing.
- Eliminated useless updates of child components.
- Obtained a totally stable wrapper for callback props that can be used in
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.
Top comments (0)