useCallback

A function declared inside a component is a new object on every render, so passing one to a memoized child defeats the shallow comparison exactly the way an inline object does. useCallback(fn, deps) returns the same function instance for as long as every dependency matches. It never calls fn and does not make it faster; all it changes is the address the child receives.

The same memoized child, two kinds of handlerHTML
<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",
  "react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>
<script type="text/babel" data-type="module">
  import React, { memo, useCallback, useState } from 'react';
  import { createRoot } from 'react-dom/client';
  const n = { fresh: 0, cached: 0 };
  const Child = memo(function Child({ id, onPing }) {
    n[id]++;
    return <li onClick={onPing}>handler from {id} — render count {n[id]}</li>;
  });
  function App() {
    const [count, setCount] = useState(0);
    const fresh = () => {};                        // new address every render
    const cached = useCallback(() => {}, []);      // same address every render
    return <div style={{ font: '15px system-ui' }}>
      <button onClick={() => setCount((c) => c + 1)}>Clicked {count} times</button>
      <ul><Child id="fresh" onPing={fresh} /><Child id="cached" onPing={cached} /></ul>
    </div>;
  }
  createRoot(document.getElementById('root')).render(<App />);
  [100, 200, 300].forEach((t) => setTimeout(() => document.querySelector('button').click(), t));
</script>
Browser output of Listing 2.52
Browser output of 52

Same component, same work, same number of parent renders: the child holding the fresh arrow renders four times, the one holding the cached arrow renders once. useCallback alone would have changed nothing — it pays off only because Child is wrapped in memo. Wrapping handlers in a component whose children are not memoized is pure overhead.

The dependency array is where this goes wrong. useCallback(() => save(text), []) keeps one function forever, holding the text from the first render — a stale closure that saves an empty string no matter what the user typed. Listing every reactive value it reads ([text]) fixes the staleness and destroys the stability, since the identity now changes on every keystroke. When you need both, the answer is useEffectEvent (Separating Events from Effects), which replaces the hand-rolled "write the value into a ref, read it back inside a useCallback" trick.