Many currently popular and widely believed articles written by web developers, front-end engineers, and gurus all claim that more often than not, people should not use React.useCallback. The claims' reasons are:
The first reason is technically true, but practically insignificant. It adds knowing when you want to update a function reference. If this is too much for someone, there are tools available which report at the very least what dependencies should be listed. If someone is using React at all it should be expected that they understand at a basic level how components rerender, since the React documentation explains it at the very beginning. To me this is comparable to saying putting a fork in a kitchen drawer adds unnecessary complexity to using it: just put all your forks on the counter-top!
On my initial round of research I accepted the overwhelming popular statement that useCallback, when used unnecessarily, will lead to worse resource usage. A particular explanation that convinced me was that, in all cases, the JavaScript VM will create another function instance:
... React.useCallback(() => setState({ ok: 1 })) vs () => setState({ ok: 1 }) ...
This is absolutely true. It's logical to follow then that the main difference is not memory, but one of performance. At the end of the day useCallback is used to return the same reference (making a stable reference to the function) for child components to check, but still the VM cannot be stopped when re-evaluating the code and creating a new function, even if it will be thrown out immediately.
What everyone doesn't mention though is the JIT will marginalize the performance cost by optimizing this "hot path". Additionally useCallback is a wrapper that's around useMemo which defers execution via React's internal dispatcher. At 60fps, useCallback takes 0.1 milliseconds longer than not using it.
The reality is using useCallback has an insignificant memory and performance difference and is justifiably zero-cost.
It does ring true then that useCallback is purely to prevent the scenario of a complex sub-tree of components rerendering when one of their callback props change. A good application methodology then is apply useCallback only on callbacks passed to children that also have children. If those children take the callback, but don't have children, the change in function reference will not cause a deeper re-render.
I've setup a few test files that you can use to recreate the test data below.
Each test was run 7 times sequentially (all without first, all with second) from a cold start (using chromium --enable-precise-memory-info --auto-open-devtools-for-tabs file). You must have the tab focused when testing otherwise your results will differ. A non-focused tab will perform better and throw off results.
The tests requires to start Chrome/Chromium with –-enable-precise-memory-info so
that process.memory returns accurate information. Make sure you have no other
browser instance open otherwise it won't take effect.
One child | W/o useCallback | W/o useCallback (inlined) | W/ useCallback |
---|---|---|---|
3454406 | 2789346 | 2847050 | |
2897878 | 2739638 | 2762998 | |
2728806 | 2771726 | 2535510 | |
2824090 | 2595950 | 2813598 | |
2757570 | 2545514 | 2801530 | |
2714706 | 2688626 | 2828290 | |
2518994 | 2767474 | 2721326 | |
Average | 2842350 | 2699753 | 2758614 |
Here we see at its simplest, inlining is better if useCallback is not
used. Otherwise not using consumes an additional 100Kb of memory for this
extremely simple case.
How does this scale to more complex cases?
Many children | W/o useCallback | W/o useCallback (inlined) | W/ useCallback |
---|---|---|---|
3870615 | 4254037 | 4257336 | |
3896047 | 4340052 | 3911144 | |
4160058 | 3943352 | 4257607 | |
4367163 | 4209900 | 3810281 | |
2648350 | 4075612 | 2650831 | |
3960474 | 3920840 | 3885064 | |
4001330 | 3420308 | 3956335 | |
Average | 3843433 | 4023443 | 3818371 |
For a slightly more complex scenario, we see resource usage scales quite well. With this we can justify that useCallback is practically zero-cost (in both cases it out-performs actually!).
Notably the inlined version has become the worst! This is because a new function is being created for each child, rather than re-using one bound earlier to a variable.:::::::::::::: 1-many-children-without.html :::::::::::::: <html> <head> <title></title> <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> </head> <body> <div id="root"></div> <script> const totalChildren = 100; const Test = () => { const [numberClicked, setNumberClicked] = React.useState(0); const onClick = (n) => setNumberClicked(n); const children = Array.from(Array(totalChildren)).map((_, i) => React.createElement(UsesCallback, { n: i, cb: onClick, numberClicked }, null)); return React.createElement('div', { id: 'test' }, children); }; const UsesCallback = ({ cb, n, numberClicked }) => { return React.createElement('div', { id: 'child' + n, onClick: () => cb(n) }, 'child' + n + ((numberClicked === n) ? '- me!' : '')); } const root = ReactDOM.createRoot(document.querySelector('#root')); root.render(React.createElement(Test, null, null)); let i = 0; let p = performance.now(); setInterval(() => { document.querySelector('#child' + i).click(); i = (i + 1) % totalChildren; }, 1000 / 60); setTimeout(() => { console.log(performance.memory.usedJSHeapSize); console.log(performance.now() - p); }, 1000 * 10); </script> </body> </html> :::::::::::::: 2-many-children-without-inline.html :::::::::::::: <html> <head> <title></title> <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> </head> <body> <div id="root"></div> <script> const totalChildren = 100; const Test = () => { const [numberClicked, setNumberClicked] = React.useState(0); const children = Array.from(Array(totalChildren)).map((_, i) => React.createElement(UsesCallback, { n: i, cb: (n) => setNumberClicked(n), numberClicked }, null)); return React.createElement('div', { id: 'test' }, children); }; const UsesCallback = ({ cb, n, numberClicked }) => { return React.createElement('div', { id: 'child' + n, onClick: () => cb(n) }, 'child' + n + ((numberClicked === n) ? '- me!' : '')); } const root = ReactDOM.createRoot(document.querySelector('#root')); root.render(React.createElement(Test, null, null)); let i = 0; setInterval(() => { document.querySelector('#child' + i).click(); i = (i + 1) % totalChildren; }, 1000 / 60); setTimeout(() => console.log(performance.memory.usedJSHeapSize), 1000 * 10); </script> </body> </html> :::::::::::::: 3-many-children-with.html :::::::::::::: <html> <head> <title></title> <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> </head> <body> <div id="root"></div> <script> const totalChildren = 100; const Test = () => { const [numberClicked, setNumberClicked] = React.useState(0); const onClick = React.useCallback((n) => setNumberClicked(n), [numberClicked]); const children = Array.from(Array(totalChildren)).map((_, i) => React.createElement(UsesCallback, { n: i, cb: onClick, numberClicked }, null)); return React.createElement('div', { id: 'test' }, children); }; const UsesCallback = ({ cb, n, numberClicked }) => { return React.createElement('div', { id: 'child' + n, onClick: () => cb(n) }, 'child' + n + ((numberClicked === n) ? '- me!' : '')); } const root = ReactDOM.createRoot(document.querySelector('#root')); root.render(React.createElement(Test, null, null)); let i = 0; let p = performance.now(); setInterval(() => { document.querySelector('#child' + i).click(); i = (i + 1) % totalChildren; }, 1000 / 60); setTimeout(() => { console.log(performance.memory.usedJSHeapSize); console.log(performance.now() - p); }, 1000 * 10); </script> </body> </html>