React gives you a whole toolbox of hooks — some you use daily (useState
), some you only dust off when things start feeling… slow.
useCallback
is one of those “performance hooks” that often gets thrown around in conversations about avoiding unnecessary work.
The problem?
A lot of devs either overuse it (wrapping every function “just in case”) or misuse it (expecting it to magically speed things up).
In reality, useCallback
is a very specific tool with a very specific job — and once you get that, you can use it with confidence instead of guesswork.
This guide is your roadmap — starting from the mental model, moving through common patterns, and ending with a simple checklist you can use to decide if useCallback
is worth it in any given situation.
Table of Contents
- Why
useCallback
Exists - Building the Mental Model
- The API & First Examples
- When
useCallback
Helps vs. When It’s Useless
Why useCallback
Exists
Imagine this:
You’ve built a super-optimized React app.
You’ve even wrapped some of your components in React.memo
to prevent unnecessary re-renders.
But then… something strange happens.
Even though the props look the same, your “memoized” child component keeps re-rendering every time the parent updates. 😤
The culprit?
Inline functions.
In JavaScript, functions are objects.
And just like {}
creates a new object every time you run it,
() => {}
creates a new function every time your component renders.
That means:
<MyChild onClick={() => console.log('clicked')} />
…creates a fresh onClick
function object every render.
From React’s point of view, the onClick
prop changed —
different reference → re-render triggered.
Where useCallback
comes in
useCallback
is basically useMemo
for functions.
It says:
“Hey React, give me the same function reference unless one of these dependencies changes.”
So instead of creating a new function each render:
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
React will hand you back the same exact function object every time — until something in []
changes.
Big picture difference from useMemo
-
useMemo
caches a value (result of a calculation). -
useCallback
caches a function (so the reference stays stable).
The goal isn’t to make the function itself faster —
it’s to make sure React doesn’t think “new function” and cause unnecessary updates.
Building the Mental Model
Here’s the thing:
Every time your React component runs (renders), it’s like React is calling your component function from scratch.
That means everything inside gets recreated — variables, objects, functions — unless you explicitly tell React to keep something the same.
Functions are objects too
In JavaScript, a function isn’t some magical, immutable entity — it’s just another object type.
So when you do:
function MyComponent() {
const greet = () => console.log('Hi!');
// ...
}
Every render:
- React calls
MyComponent()
again. - A new
greet
function is created in memory. - Even if it looks identical, its reference is different.
React’s reference equality check
When React decides whether a component’s props have changed, it uses shallow comparison — it checks if the reference is the same, not if the content “looks” the same.
That means:
() => console.log('Hi!') !== () => console.log('Hi!')
They’re two different function objects in memory.
Why this matters for props
If you pass a new function to a child on every render:
<MyChild onClick={() => console.log('clicked')} />
React sees:
-
Old render:
onClick
→ Function A -
New render:
onClick
→ Function B (new reference)
Even though Function A and Function B do the exact same thing, React thinks:
“Prop changed — better re-render the child.”
Where useCallback
changes the game
When you wrap your function in useCallback
:
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
React stores the function once, and hands you back the same reference each render — until a dependency changes.
Now, the child sees:
- Render 1:
onClick
→ Function A - Render 2:
onClick
→ Function A (same reference) ✅
Result? No unnecessary re-render.
Analogy time:
Think of useCallback
like giving React a permanent phone number for your function.
Without it, you’d be giving out a new number every time you spoke — and your friends (child components) would think they have to reintroduce themselves.
The API & First Examples
The syntax
useCallback
looks almost exactly like useMemo
:
const memoizedFn = useCallback(
(/* arguments */) => {
// function body
},
[/* dependencies */]
);
What happens here?
- On the first render, React stores your function.
-
On later renders, React checks the dependency array:
- If nothing in the array changed → return the same function reference as last time.
- If something did change → create and store a new function.
Example 1 — Preventing unnecessary child re-renders
Let’s start simple. We have a memoized child component:
const Child = React.memo(function Child({ onClick }) {
console.log('Child rendered');
return <button onClick={onClick}>Click Me</button>;
});
And a parent:
function Parent() {
const [count, setCount] = React.useState(0);
const handleClick = useCallback(() => {
console.log('Button clicked');
}, []);
return (
<>
<Child onClick={handleClick} />
<button onClick={() => setCount(count + 1)}>Increment</button>
<p>Count: {count}</p>
</>
);
}
Why this works:
-
Child
is wrapped inReact.memo
, so it skips re-rendering if its props haven’t changed. - Without
useCallback
, a newhandleClick
reference would be created every timeParent
re-rendered, causingChild
to render again unnecessarily. - With
useCallback
,handleClick
stays the same between renders, soChild
only renders once.
Example 2 — Stable callbacks for complex components
Some components (like charts, maps, or custom widgets) attach event listeners when they mount — and they might remove & re-add those listeners every time the callback reference changes.
Example:
function ChartWrapper({ data }) {
const handlePointClick = useCallback((point) => {
console.log('Point clicked:', point);
}, []);
return <MyBigChart data={data} onPointClick={handlePointClick} />;
}
Without useCallback
, MyBigChart
might think:
“Oh, new onPointClick function? Better re-bind all my event listeners.”
With useCallback
, the reference stays stable, so setup work only happens once.
Dependency arrays matter
If your function depends on values from the component scope, you must list them in the dependency array:
const handlePointClick = useCallback((point) => {
console.log(point, filterValue); // filterValue is from scope
}, [filterValue]);
If you leave filterValue
out, you’ll get stale closures — your function will “freeze” with the old value.
When useCallback
Helps vs. When It’s Useless
It looks like the issue arises from how the markdown is being formatted. In markdown, sections are typically delineated by headings like ##
(for subheadings), but in your case, I think you're expecting those sections to be separate, each starting with a proper heading.
When useCallback
Helps
useCallback
is not a magic performance booster for every function. It shines in specific situations:
1. Preventing unnecessary re-renders in memoized children
If you’re passing a function as a prop to a child wrapped in React.memo
, useCallback
ensures the function reference stays stable between renders, preventing unnecessary re-renders.
const handleClick = () => {
console.log("Button clicked!");
};
const MemoizedChild = React.memo(({ onClick }) => {
console.log("Child re-rendered");
return <button onClick={onClick}>Click Me</button>;
});
const Parent = () => {
return <MemoizedChild onClick={useCallback(handleClick, [])} />;
};
In this case, handleClick
will not change between renders, and the MemoizedChild
will not re-render unless its other props change.
2. Keeping dependencies stable for useEffect
or useMemo
If you pass a function into a useEffect
or useMemo
dependency array, React will re-run the effect or memoization if the function reference changes. useCallback
ensures that the function remains stable and the effect is only triggered when necessary.
const fetchData = useCallback(() => {
fetch("/api/data")
.then((response) => response.json())
.then((data) => setData(data));
}, []); // Stable dependency
useEffect(() => {
fetchData();
}, [fetchData]); // Will not run unless fetchData changes
Without useCallback
, the fetchData
function would be considered a new reference on every render, causing the effect to re-run unnecessarily.
3. Avoiding expensive teardown/setup in third-party components
Some UI components (like charts, maps, or editors) may attach event listeners when mounted and tear them down when the handler changes. By using useCallback
, you avoid reattaching event listeners unnecessarily.
const handleResize = useCallback(() => {
console.log("Window resized");
}, []);
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [handleResize]); // Only re-add listener when handleResize changes
Here, handleResize
remains stable, preventing unnecessary listener attachment and detachment on each render.
4. Functions in deeply nested prop chains
When you pass callbacks several layers deep, you don’t want each layer re-rendering just because the top component got a new function reference.
const handleClick = useCallback(() => {
console.log("Click event in deeply nested component");
}, []);
const Child = ({ onClick }) => {
return <GrandChild onClick={onClick} />;
};
const GrandChild = ({ onClick }) => {
return <button onClick={onClick}>Click Me</button>;
};
const Parent = () => {
return <Child onClick={handleClick} />;
};
In this example, useCallback
ensures that handleClick
doesn’t change unnecessarily, avoiding re-renders of Child
and GrandChild
.
When useCallback
is Useless (or Harmful)
useCallback
is not free — it adds a bit of overhead for React to keep track of your memoized function. If you use it everywhere, you may actually slow things down.
1. The child isn’t memoized
If the child component re-renders every time anyway, then useCallback
won’t make a difference. There’s no memoization, so the function reference stability doesn’t matter.
const handleClick = () => {
console.log("Clicked!");
};
const Child = ({ onClick }) => {
console.log("Child re-rendered");
return <button onClick={onClick}>Click Me</button>;
};
const Parent = () => {
return <Child onClick={useCallback(handleClick, [])} />;
};
In this case, Child
is not memoized, so wrapping handleClick
with useCallback
does not prevent the re-render of Child
.
2. The function is cheap and the component renders rarely
If the function body is tiny (like setting state) and the component doesn’t render often, there’s no measurable performance gain.
const handleClick = () => setCount((prevCount) => prevCount + 1);
const Parent = () => {
return <button onClick={handleClick}>Increment</button>;
};
Since the function is small and the component is unlikely to re-render often, using useCallback
here would add unnecessary overhead.
3. Premature optimization
If you haven’t measured the problem, you might be solving an issue that doesn’t exist. Use React DevTools Profiler to see if function props are really causing re-renders.
const handleClick = () => {
console.log("Button clicked");
};
const Parent = () => {
return <MemoizedChild onClick={handleClick} />;
};
In this case, if there’s no performance issue (e.g., the MemoizedChild
doesn't re-render unnecessarily), using useCallback
might be premature optimization.
Rule of Thumb:
Reach for useCallback
when:
- You have a memoized child
- The only prop that changes is a function
- That change triggers a useless re-render
Otherwise, keep it simple.
Real-World Patterns
Once you understand what useCallback
does, the fun part is spotting where it naturally solves problems in real projects.
Here are some patterns you’ll see all the time.
1. Event handler stability for memoized children
The problem: You have a React.memo
child that takes an event handler prop. Without useCallback
, it re-renders every time the parent updates.
Example:
const ListItem = React.memo(function ListItem({ onSelect, label }) {
console.log('Rendered:', label);
return <li onClick={onSelect}>{label}</li>;
});
function List({ items }) {
const handleSelect = useCallback((item) => {
console.log('Selected:', item);
}, []);
return (
<ul>
{items.map((item) => (
<ListItem
key={item}
label={item}
onSelect={() => handleSelect(item)}
/>
))}
</ul>
);
}
Now, only the list items that actually need to re-render will do so.
2. Stable callbacks for custom hooks
Some custom hooks accept callbacks, and those callbacks might go into a dependency array internally. If you don’t use useCallback
, you can cause the hook’s internal effects to run on every render.
Example:
function useWindowResize(callback) {
React.useEffect(() => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
}, [callback]);
}
function App() {
const onResize = useCallback(() => {
console.log('Resized!');
}, []);
useWindowResize(onResize);
return <div>Resize me!</div>;
}
Without useCallback
, the listener would be re-added on every render.
3. Debouncing or throttling without re-creating
If you wrap a function with debounce
or throttle
, you don’t want that wrapped function to be recreated every render — it would reset its internal timer.
Example:
import { debounce } from 'lodash';
function SearchInput() {
const [query, setQuery] = React.useState('');
const sendQuery = useCallback(
debounce((q) => console.log('Searching for:', q), 300),
[]
);
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
sendQuery(e.target.value);
}}
/>
);
}
Here, useCallback
ensures the debounced function lives across renders.
4. Avoiding infinite effect loops
A common beginner trap:
useEffect(() => {
doSomething();
}, [someFunction]);
If someFunction
is created inline and not memoized, this effect will run on every render.
useCallback
makes someFunction
stable so the effect runs only when it should.
Pattern takeaway:
useCallback
is like an insurance policy for function references in situations where stability matters — memoized children, effect dependencies, event bindings, and wrapped utilities.
Measuring First
Before you start sprinkling useCallback
everywhere like seasoning, you need to know:
“Is this function reference problem actually slowing down my app?”
The truth is — many components re-render happily with zero performance issues. Adding useCallback
unnecessarily can make your code more complex without any benefit.
Step 1 — Spotting the symptoms
Signs that useCallback
might help:
- You have a memoized child (
React.memo
) that still re-renders when the parent updates. - You see functions in dependency arrays causing effects to run too often.
- You have expensive setup/teardown in a child or third-party component when a callback changes.
Step 2 — Use the React DevTools Profiler
The React DevTools Profiler (in your browser’s dev tools) shows:
- How many times each component rendered.
- Why it rendered — including “props changed” as a reason.
If you see a child re-rendering due to a changed function prop, that’s a candidate for useCallback
.
Step 3 — Test before & after
Wrap your function in useCallback
and re-run the profiler.
- Did the extra re-renders disappear? ✅ Great — you found a win.
- No difference? ❌ Revert — you don’t need it here.
Step 4 — Keep a mental checklist
Before using useCallback
, ask:
- Is there a measurable problem? (Profiler says yes)
- Is a function prop causing it?
- Will keeping the reference stable fix it?
If any answer is “no,” you can probably skip it.
Bottom line:
Measure first. Optimize second. Don’t let useCallback
become a knee-jerk habit — use it as a targeted fix.
Pitfalls & Mistakes
Like most hooks, useCallback
is powerful — but it’s easy to misuse it in ways that either do nothing or actively cause problems.
Let’s look at the most common traps.
1. Missing dependencies → stale closures
If your callback uses values from the component scope, you must include them in the dependency array.
Bad:
const handleClick = useCallback(() => {
console.log(count); // count is from scope
}, []); // ❌ Missing dependency
Here, handleClick
will “freeze” with the count value from the first render.
When count
changes, it still logs the old number.
Good:
const handleClick = useCallback(() => {
console.log(count);
}, [count]); // ✅ Include it
2. Overusing without benefit
Some developers wrap every function in useCallback
“just in case.”
This adds unnecessary complexity, makes code harder to read, and slightly increases render overhead.
If there’s no measurable problem, skip it.
3. Using useCallback
for side effects
useCallback
is not for running code in response to changes — it’s for returning a stable function reference.
If you want to run code when something changes, use useEffect
.
4. Forgetting that it doesn’t memoize the result
useCallback
doesn’t store what the function returns — it stores the function itself.
If you want to memoize a computed value, use useMemo
.
5. Thinking it always improves performance
In some cases, useCallback
can actually make your app slower, because:
- React has to store and compare the memoized function.
- You may still be re-rendering for other reasons.
- The function itself is cheap to recreate.
Rule:
Only reach for useCallback
when:
- The function is passed to a memoized child or
- The function is in a dependency array and
- Stabilizing the reference will avoid unnecessary work.
Wrap-up & Mental Checklist
We’ve gone from “useCallback
is just useMemo
for functions” to knowing exactly when and why to use it — and when it’s just clutter.
Your Quick useCallback
Checklist
Before adding it, ask yourself:
Is there a measurable performance problem?
Check with the React DevTools Profiler first.Is a function reference causing it?
For example:
- A memoized child re-rendering because of a changing callback.
- An effect running too often because the function in its dependency array keeps changing.
Will stabilizing the function reference fix it?
If the issue is caused by something else,useCallback
won’t help.Are your dependencies correct?
Missing dependencies = stale closures.
Key Takeaways
- What it does: Caches a function reference until dependencies change.
- Why it matters: Keeps props/effect dependencies stable, avoiding useless re-renders or effect runs.
- When it’s useful: Memoized children, dependency arrays, event listener setup.
- When to skip: Non-memoized children, cheap functions, rare renders.
Analogy to remember:
useCallback
is like giving React your function’s phone number —
if the number stays the same, React won’t bother calling back unnecessarily.
Change the number only when you actually move house (dependencies change).
Up Next:
The Definitive React 19 useId Guide — Patterns, Pitfalls, and Pro Tips →
Follow me on DEV for future posts in this deep-dive series.
https://dev.to/a1guy
If it helped, leave a reaction (heart / bookmark) — it keeps me motivated to create more content
Want video demos? Subscribe on YouTube: @LearnAwesome