About
This isn't a problem, just trying to gain more insight into an idea which some colleagues and I thought was cool.
We are able to achieve fewer re-renderings by using the function below rather than useCallback
.
Imagine a bigger codebase, with callbacks that gets propagated from child to grand child over many levels.
This solution allows the creation of memoized callbacks that run in a scope which may change, but which does not change the reference to the callback itself.
Please take a close look at the following function (and yep, callbackContainer.current = callback
is meant to be there)
function useStableCallback<T>(callback: () => T) {
const callbackContainer = useRef(callback);
callbackContainer.current = callback;
return useCallback(() => callbackContainer.current(), [callbackContainer]);
}
Why this pattern?
Let me start with an example of how this was a non-issue with class components
With React class components it was easy to give child components stable callback functions as props.
In the following example the child component (MyChildComponent
) receives a stable reference to a callback than can be used to refresh some data.
Point: The child component uses a function that depends on state which the child component itself does NOT depend on, (i.e.
this.props.url
on the parent).
If the parent receives a new url, this doesn't cause re-rendering of the child. Because the onRefresh
callback exists on the parent object, it does not change.
Useful!
function MyChildComponent({
onRefresh,
title,
body
}: {
onRefresh: () => any;
title: string;
body: string;
}) {
return (
<article>
<SomeOtherChildComponent title={title} body={body} />
<button onClick={onRefresh}>Refresh</button>
</article>
);
}
interface ParentState {
title?: string;
body?: string;
isLoading: boolean;
}
interface ParentProps {
url: string;
}
class MyParentComponent extends React.Component<ParentProps> {
state: ParentState = { isLoading: false };
refresh = () => {
this.setState({ isLoading: true });
fetch(this.props.url)
.then(r => r.json())
.then(r =>
this.setState({
title: r.title,
body: r.body,
isLoading: false
})
);
};
render() {
return this.state.isLoading ? (
<h3>Loading...</h3>
) : (
<MyChildComponent
title={this.state.title}
body={this.state.body}
onRefresh={this.refresh}
/>
);
}
}
Now with hooks
In the following block MyParentComponent
as been implemented as a function with hooks.
Uh-oh! As we can see, the refresh
-function will now change every time MyParentComponent
receives a new url!
function MyParentComponent({ url }: ParentProps) {
const [isLoading, setIsLoading] = useState(false);
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const refresh = useCallback(() => {
setIsLoading(true);
fetch(url)
.then(r => r.json())
.then(r => {
setIsLoading(false);
setTitle(r.title);
setBody(r.body);
});
}, [url, setIsLoading, setTitle, setBody]);
return isLoading ? (
<h3>Loading...</h3>
) : (
<MyChildComponent title={title} body={body} onRefresh={refresh} />
);
}
With suggested pattern
refresh
can be defined as follows:
const refresh = useStableCallback(() => {
setIsLoading(true);
fetch(url)
.then(r => r.json())
.then(r => {
setIsLoading(false);
setTitle(r.title);
setBody(r.body);
});
});
This is cool because...
- The
refresh
function will never change (and thus won't cause re-renderings) - It brings some handy functionality from class components into the world of functional components
- The function will always run in the most recent scope of the component in which it was defined. In other words:
refresh
knows about changes tourl
, even though the function itself did not change.
Is it a good pattern?
This definitely helps us to avoid a bunch of re-renderings, but are there any potential problems with this pattern? What do you think?