TL;DR: useCallback caches a function so React doesn't recreate it every render. This matters when you're passing callbacks to child components wrapped in React.memo — without it, the child sees a "new" function every render and re-renders unnecessarily. Without React.memo on the child, useCallback does essentially nothing.
Why AI Coders Need This
Open any React project that AI helped you build. Search for useCallback. You'll find it everywhere — wrapping click handlers, form submissions, toggle functions, API calls. AI tools like Cursor and Claude Code add useCallback to functions the way some people add "please" to every sentence. Polite? Sure. Always necessary? Absolutely not.
Here's the thing: useCallback is one of the most misused React hooks because it sounds like a performance win. "Cache my function? Obviously that's better than recreating it!" But the reality is more nuanced. In many cases, useCallback adds complexity and overhead without any measurable benefit.
This guide shows you exactly when useCallback earns its place in your code — and when AI is just adding noise.
The Problem: React Recreates Functions Every Render
Every time a React component renders, everything inside it runs again. That includes function definitions. Watch what happens here:
function ParentComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// This function is RECREATED every single render
const handleClick = () => {
console.log('Button clicked!');
};
// This one too
const handleSubmit = () => {
saveToDatabase(name);
};
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveChildComponent onClick={handleClick} />
<FormSection onSubmit={handleSubmit} />
</div>
);
}
Every time the user types a letter in the input, name changes, the parent re-renders, and React creates brand new handleClick and handleSubmit functions. They do exactly the same thing as before — but they're different objects in memory.
Why does this matter? Because of how JavaScript compares things:
const funcA = () => console.log('hello');
const funcB = () => console.log('hello');
// These look identical, but...
funcA === funcB // false! Different objects in memory
So when React checks whether ExpensiveChildComponent received new props, it sees a "new" onClick function every render — even though the function does the exact same thing. If that child is wrapped in React.memo (which tells React "skip re-rendering if props haven't changed"), the optimization fails. React.memo sees a "different" function and re-renders the child anyway.
That's the specific problem useCallback solves. Not "functions are expensive to create" (they're not — JavaScript creates functions in microseconds). The problem is referential identity — giving child components the same function reference so they can skip unnecessary re-renders.
Without useCallback: The Child Re-Renders Every Time
// A child component that's expensive to render
const ExpensiveList = React.memo(({ onItemClick, items }) => {
console.log('ExpensiveList rendered!'); // Watch this in console
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onItemClick(item.id)}>
{item.name} — ${item.price.toFixed(2)}
</li>
))}
</ul>
);
});
function SearchPage() {
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState(null);
const items = useProducts(); // Imagine 1,000 products
// ❌ New function created every render
const handleItemClick = (id) => {
setSelectedId(id);
trackAnalytics('item_click', id);
};
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search products..."
/>
<ExpensiveList items={items} onItemClick={handleItemClick} />
</div>
);
}
Open your browser console with this code. Type in the search box. You'll see "ExpensiveList rendered!" logged on every single keystroke — even though the list items haven't changed. Why? Because handleItemClick is a new function every render, so React.memo thinks the props changed.
With 1,000 list items, each keystroke triggers a full re-render of the list. The user types "shirt" and the UI stutters because React is doing the work of rendering 1,000 list items five times (once per letter).
With useCallback: The Child Skips the Re-Render
function SearchPage() {
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState(null);
const items = useProducts();
// ✅ Same function reference between renders
const handleItemClick = useCallback((id) => {
setSelectedId(id);
trackAnalytics('item_click', id);
}, []); // Empty deps — this function never needs to change
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search products..."
/>
<ExpensiveList items={items} onItemClick={handleItemClick} />
</div>
);
}
Now type in the search box. "ExpensiveList rendered!" appears only on the initial render. When you type, the parent re-renders (because query changes), but handleItemClick is the same function reference. React.memo on ExpensiveList sees that both items and onItemClick are unchanged — so it skips the re-render entirely.
The user types "shirt" and the input responds instantly because React skips rendering 1,000 list items on each keystroke.
How useCallback Works (The Mental Model)
Think of useCallback like a name badge at a conference:
- First render: React creates your function and puts a name badge on it. "Hi, I'm handleClick."
- Next render: React checks the dependency array. Did any dependencies change?
- If nothing changed: React hands back the same function with the same name badge. Child components recognize it: "Oh, it's the same handleClick. No need to re-render."
- If a dependency changed: React creates a new function with a new name badge. Child components see a new person and react accordingly.
The syntax:
const cachedFunction = useCallback(
(args) => {
// your function body
doSomething(args, dependency1);
},
[dependency1] // ← recreate this function only when dependency1 changes
);
The dependency array is critical. It tells React: "This function only needs to be a new version when these values change." Any variable from the component scope that the function uses must be in the dependency array — otherwise the function closes over stale values.
// ❌ Bug: selectedId is always the value from the first render
const handleClick = useCallback((id) => {
if (id === selectedId) return; // selectedId is stale!
setSelectedId(id);
}, []); // Missing selectedId in deps
// ✅ Correct: function updates when selectedId changes
const handleClick = useCallback((id) => {
if (id === selectedId) return;
setSelectedId(id);
}, [selectedId]);
The Two-Part Rule: useCallback Is Only Half the Story
This is the thing AI gets wrong most often. useCallback alone does not prevent re-renders. It's a two-part system:
- useCallback on the parent — preserves a stable function reference
- React.memo on the child — tells React to skip re-rendering when props haven't changed
Without both parts, you're doing half the work for zero benefit:
// ❌ useCallback without React.memo — POINTLESS
function Parent() {
const handleClick = useCallback(() => {
doSomething();
}, []);
// Child re-renders every time Parent renders regardless
return <Child onClick={handleClick} />;
}
// Child is NOT wrapped in React.memo, so it always re-renders
function Child({ onClick }) {
return <button onClick={onClick}>Click me</button>;
}
// ✅ useCallback WITH React.memo — Actually works
function Parent() {
const handleClick = useCallback(() => {
doSomething();
}, []);
return <Child onClick={handleClick} />;
}
// React.memo checks: "Did props change? No? Skip re-render."
const Child = React.memo(function Child({ onClick }) {
return <button onClick={onClick}>Click me</button>;
});
Think of it like a lock and a key. useCallback is the key (stable reference). React.memo is the lock (checks for changes). A key without a lock doesn't secure anything. A lock without a key just blocks everyone.
useCallback vs useMemo: Same Engine, Different Cargo
These two hooks confuse everyone — including AI. Here's the clear difference:
| Hook | What It Caches | Returns | Use When |
|---|---|---|---|
useCallback | A function definition | The function itself | Passing callbacks to memoized children |
| useMemo | A computed value | The result of calling a function | Expensive calculations, derived data |
They're literally built on the same mechanism. In fact, useCallback(fn, deps) is exactly identical to useMemo(() => fn, deps). React even implements them that way internally.
// useCallback — caches the FUNCTION
const handleDelete = useCallback((id) => {
deleteItem(id);
showToast('Deleted!');
}, []);
// useMemo — caches the RESULT
const sortedItems = useMemo(() => {
return items.sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
// They are NOT interchangeable:
// ❌ Don't use useMemo to cache a function (use useCallback)
const handleClick = useMemo(() => () => doSomething(), []);
// ❌ Don't use useCallback for a calculation (use useMemo)
const total = useCallback(() => items.reduce((sum, i) => sum + i.price, 0), [items]);
// This returns the function, NOT the total!
Quick decision guide: Are you caching something you want to call later? Use useCallback. Are you caching something you want to use right now as a value? Use useMemo.
When AI Is Right to Add useCallback
- Callbacks passed to
React.memochildren: The classic use case. A handler passed to a memoized child component that's expensive to render (long lists, data tables, charts). - Functions used as useEffect dependencies: If a function is in a
useEffectdependency array, wrapping it inuseCallbackprevents the effect from running on every render. - Functions passed to custom hooks: If a custom hook uses your function in its own dependency array, a stable reference prevents unnecessary re-execution.
- Event handlers in context providers: Functions in a React Context value are consumed by many components. A new function reference causes every consumer to re-render.
- Debounced or throttled handlers: If you're wrapping a function with
debounce()orthrottle(), a stable reference prevents creating a new debounced version every render.
When useCallback Is Overkill
- The child isn't memoized: No
React.memoon the child?useCallbackdoes nothing useful. The child re-renders regardless. - The function isn't passed down: If
handleClickis only used in the same component (like on a<button>directly in the parent), there's no child to optimize. - The component is simple: A child that renders a single
<button>re-renders in microseconds. Preventing that re-render saves nothing measurable. - Dependencies change every render: If your
useCallbackdepends on state that changes every render, it creates a new function every render anyway — same as not usinguseCallback, but with extra overhead. - You're in a leaf component: Components at the bottom of the tree with no children have nothing to pass callbacks to.
The honest truth: Most useCallback calls in AI-generated code are unnecessary. AI adds them as a "best practice" without checking whether the conditions for them to actually help are met. It's like putting a "Caution: Wet Floor" sign in the desert.
What AI Gets Wrong About useCallback
The most common mistake. AI wraps every handler in useCallback but never adds React.memo to the child components receiving those callbacks. The useCallback preserves a stable reference that nobody checks. It's like sealing a letter that goes into an open mailbox. Fix: Ask AI: "Which child components are wrapped in React.memo? If none, these useCallbacks aren't doing anything."
AI treats useCallback like a magical performance enhancer. You'll see it wrap handleChange, handleSubmit, toggleModal, handleKeyPress — every function in the component. Each useCallback adds overhead (React has to compare dependency arrays on every render). When none of those functions are passed to memoized children, every useCallback is pure overhead. Fix: "Remove useCallback from functions that are only used in this component, not passed to children."
Same problem as useMemo — AI forgets dependencies or adds unnecessary ones. A missing dependency means the function closes over a stale value and uses outdated data. An unnecessary dependency means the function recreates more often than needed. Fix: Install eslint-plugin-react-hooks and enable the exhaustive-deps rule. It catches these automatically.
AI sometimes writes this pattern: useCallback on the parent, but passes the callback to a component defined inline or not memoized. The worst version: useCallback in the parent, passed to {items.map(item => <Child key={item.id} onClick={handleClick} />)} where Child is a regular component. No React.memo means no benefit. Fix: "Is Child wrapped in React.memo? If not, this useCallback is unnecessary."
AI writes useCallback functions that create and pass new objects to children: onClick={() => handleAction({ id, type: 'delete' })}. Even though handleAction is stable, the object argument is created fresh each call. This doesn't break useCallback itself, but if the child uses that object in its own memoization logic, the optimization fails at a different level. Fix: Be aware that useCallback stabilizes the function reference only — not the arguments you pass when calling it.
A Real-World Pattern: Search Page with useCallback
Here's a complete, realistic example showing useCallback used correctly — the kind of code AI should generate:
import { useState, useCallback, memo } from 'react';
// Memoized child — expensive to render (1,000+ items)
const ProductList = memo(function ProductList({ products, onSelect, onAddToCart }) {
console.log('ProductList rendered'); // Check this in DevTools
return (
<div className="product-grid">
{products.map(product => (
<div key={product.id} className="product-card">
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => onSelect(product.id)}>View</button>
<button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
</div>
))}
</div>
);
});
// Memoized child — lightweight but called from context
const CartSummary = memo(function CartSummary({ itemCount, onCheckout }) {
return (
<div className="cart-summary">
<span>{itemCount} items</span>
<button onClick={onCheckout}>Checkout</button>
</div>
);
});
function SearchPage() {
const [query, setQuery] = useState('');
const [cart, setCart] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const products = useProducts(); // 1,000+ products
// ✅ useCallback — passed to memoized ProductList
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []);
// ✅ useCallback — passed to memoized ProductList
const handleAddToCart = useCallback((id) => {
setCart(prev => [...prev, id]);
}, []);
// ✅ useCallback — passed to memoized CartSummary
const handleCheckout = useCallback(() => {
navigate('/checkout');
}, []);
// ❌ No useCallback needed — only used in this component
const handleSearch = (e) => {
setQuery(e.target.value);
};
const filteredProducts = useMemo(() =>
products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
), [products, query]
);
return (
<div>
<input value={query} onChange={handleSearch} placeholder="Search..." />
<CartSummary itemCount={cart.length} onCheckout={handleCheckout} />
<ProductList
products={filteredProducts}
onSelect={handleSelect}
onAddToCart={handleAddToCart}
/>
</div>
);
}
Notice the pattern: handleSearch does not have useCallback because it's used directly in the parent's JSX, not passed to a memoized child. The other three handlers do have useCallback because they're passed to React.memo children. Also notice that setCart uses the functional updater form (prev => [...prev, id]) so it doesn't need cart in the dependency array.
Debugging useCallback with AI
"My list component re-renders every time I type in the search box, even though I'm using useCallback. Here's the parent and child component code: [paste both]. Why is the memoization not working?"
AI is solid at diagnosing why memoization isn't working — missing dependencies, missing React.memo, objects being recreated. It's better at debugging existing useCallback problems than deciding when to add it in the first place.
"Review this component. Which useCallbacks are actually necessary (the child is wrapped in React.memo) and which can be removed? I only want useCallback where it prevents a real re-render."
The Profiler tab in React DevTools shows exactly why a component re-rendered. It will tell you "props changed: onClick" — which means your useCallback isn't working (maybe missing React.memo on the child, or a dependency is changing). This is the fastest way to find out if useCallback is actually doing its job.
React 19 and the Future of useCallback
React 19 ships with a compiler (internally called "React Forget") that automatically memoizes components and hooks. The long-term vision: you write plain functions, and the compiler figures out what needs useCallback, useMemo, and React.memo automatically.
Does that mean you can ignore useCallback? Not yet. The compiler is opt-in, only works with certain build setups, and isn't universal. More importantly, understanding why function identity matters in React helps you debug performance issues regardless of whether the optimization is manual or automatic. And AI will keep generating useCallback for years — it's saturated in training data.
If you're starting a new React 19 project with the compiler enabled, you may genuinely not need manual useCallback. For everything else — existing projects, frameworks that haven't adopted the compiler, or AI-generated code you need to review — this knowledge is essential.
Frequently Asked Questions
useCallback caches a function so React reuses the same function reference between renders. useMemo caches a computed value (like a filtered list or calculation result). They use the same mechanism — dependency arrays — but useCallback returns the function itself, while useMemo returns the result of calling a function. In fact, useCallback(fn, deps) is identical to useMemo(() => fn, deps).
Not by itself. useCallback preserves a stable function reference. It only prevents re-renders when the child component receiving the callback is wrapped in React.memo. Without React.memo on the child, useCallback adds overhead for zero benefit — the child re-renders regardless because React re-renders all children when a parent re-renders.
Skip useCallback when: the function isn't passed to a child component, the child isn't wrapped in React.memo, the child is simple and renders in microseconds anyway, or the function's dependencies change on every render. Most functions in most components don't need useCallback. The React team recommends writing code without it and adding it only when you measure a performance problem.
Only in specific scenarios where it prevents expensive child component re-renders. useCallback itself adds a small overhead (tracking dependencies on every render). It pays off when the avoided re-render is more expensive than that overhead. For a child that renders a single button, the overhead of useCallback may exceed the cost of just re-rendering the child. Profile with React DevTools before assuming it helps.
That's the goal. React 19's compiler can automatically detect which functions need stable references and memoize them. But the compiler is opt-in, not yet universal, and AI will generate useCallback code for years. Understanding what it does helps you debug performance issues regardless of whether you write it manually or the compiler handles it.