REACT.USECALLBACK IS JUSTIFIABLY ZERO-COST

Or: a personal lesson about verifying claims that aren't backed by data.

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.

Evidence

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.

Files

To keep it brief I've only included the "many children" tests to clarify what's been done in this slightly more complex case. Originally there were going to be "many nested children" and finally "many nested children with many siblings", but I'm satisfied with the current evidence.
::::::::::::::
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>