Almost every explanation of useCallback says something like this:
"Wrap your functions with
useCallbackto improve performance and prevent unnecessary re-renders."
Naturally, I believed that simply adding useCallback would make my React components render less often.
So I decided to verify it.
I built a tiny React example expecting to see fewer renders and better performance.
Instead...
Nothing changed.
The component still rendered after every click. The UI behaved exactly the same.
At that point I realized I wasn't asking the right question.
The better question was:
If
useCallbackdoesn't stop components from rendering, what problem is it actually solving?
To answer that, I built three progressively more realistic examples.
We'll start with the simplest one.
Experiment 1: What does useCallback actually return?
Before talking about performance, let's ignore optimization completely and focus on one thing:
What does
useCallbackactually give us?
Here's a small component.
import { useCallback, useEffect, useState } from 'react'
export default function App() {
const [count, setCount] = useState(0)
const increment = () => {
setCount((c) => c + 1)
};
useEffect(() => {
console.log('increment reference changed')
}, [increment])
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
)
}
Now open the browser console. Click Increment five or six times. You'll notice two things.
- The component re-renders after every click (the count updates as expected).
-
increment reference changedappears after every render, because a brand newincrementfunction is created each time.
Now wrap increment function with useCallback like this:
// useCallback returns the same function reference
// until one of its dependencies changes.
const increment = useCallback(() => {
setCount(c => c + 1)
}, [])
Now you will notice the message increment reference changed appears only once.
So what changed? The rendering? No. The UI? No.
Only the function reference.
You can visualize it like this.
Without useCallback
Render #1
increment ───► Function A
Render #2
increment ───► Function B
Render #3
increment ───► Function C
Every render creates a new function object.
With useCallback
Render #1
increment ───► Function A
Render #2
increment ───► Function A
Render #3
increment ───► Function A
The component still renders. But React keeps reusing the same function object.
So... did we actually improve performance?
Not really.
The component still re-renders after every click. The button still behaves exactly the same. Nothing feels faster.
At this point, all we've proven is this:
useCallbackdoesn't stop React from rendering. It simply keeps a function reference stable between renders.
And that immediately raises another question.
If the function reference stays the same, who actually benefits from that?
Right now... nobody.
The component isn't using that stable reference for anything. So the optimization doesn't translate into any visible performance improvement.
That might sound disappointing, but it's actually the key to understanding why useCallback exists.
Key takeaways
-
useCallbackdoesn't prevent a component from re-rendering. - It doesn't make the UI magically faster.
- Its primary job is to preserve a function's identity across renders.
- On its own, that stable function reference usually doesn't provide any measurable performance benefit.
So where does it become useful? The answer lies in another React optimization: React.memo. That's where a stable function reference suddenly matters a lot.
Experiment 2: React.memo
Let's set up an employee management screen. Each employee gets their own row, showing the employee's name, role, and a Promote button.
We'll use this example to see where React.memo helps—and where it surprisingly doesn't.
import React, { useState } from 'react';
const initialEmployees = [
{ id: 1, name: 'Aditi', role: 'Frontend Engineer' },
{ id: 2, name: 'Rohan', role: 'Backend Engineer' },
{ id: 3, name: 'Priya', role: 'Product Manager' },
{ id: 4, name: 'Karan', role: 'DevOps Engineer' },
{ id: 5, name: 'Sana', role: 'UX Designer' },
];
// EmployeeRow is a normal component.
// Whenever EmployeeTable re-renders, every EmployeeRow renders again.
const EmployeeRow = ({ employee }) => {
console.log('Rendering row:', employee.name);
return (
<tr>
<td>{employee.id}</td>
<td>{employee.name}</td>
<td>{employee.role}</td>
</tr>
);
};
export default function EmployeeTable() {
const [employees, setEmployees] = useState(initialEmployees);
const promoteEmployee = (id) => {
setEmployees(prev =>
prev.map(employee =>
employee.id === id
? { ...employee, role: `${employee.role} (Lead)` }
: employee
)
);
};
return (
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Role</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{employees.map(employee => (
<React.Fragment key={employee.id}>
<EmployeeRow employee={employee} />
<tr>
<td colSpan={4}>
<button onClick={() => promoteEmployee(employee.id)}>
Promote {employee.name}
</button>
</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
);
}
Open the browser console and click Promote for any employee. You'll see:
Rendering row: Aditi
Rendering row: Rohan
Rendering row: Priya
Rendering row: Karan
Rendering row: Sana
Every single employee row renders.
React re-runs a component's function every time its parent re-renders — regardless of whether that component's props actually changed.
Here's the chain of events:
- You click a button →
setEmployeesupdates state inEmployeeTable. - State change →
EmployeeTablere-renders (its function body runs again). - During that render, React reaches
employees.map(...)and creates a fresh<EmployeeRow employee={employee} />element for every row. - For each of those elements, React calls the
EmployeeRowfunction to get its output → yourconsole.log('Rendering row:', ...)fires 5 times.
That feels like a problem. Each row represents an independent employee. If one employee changes, why should every other row render again? That's exactly what React.memo is designed to solve.
Wrap the row:
// React.memo compares the current props with the previous ones.
// If the employee prop hasn't changed, React skips re-rendering this row.
const EmployeeRow = React.memo(function EmployeeRow({ employee }) {
console.log('Rendering row:', employee.name);
return (
<tr>
<td>{employee.id}</td>
<td>{employee.name}</td>
<td>{employee.role}</td>
</tr>
);
});
Now, when you click Promote, only the row that changed logs a re-render.
React.memo wraps the component and inserts a shallow prop comparison before re-rendering.
On a parent re-render, React does:
- Compare the new employee prop with the previous one using
Object.is. - Same reference? → skip the function call → no console.log.
- New reference? → re-run → console.log fires.
The Twist: Moving Promote into EmployeeRow
The promote button logically belongs inside each row, not the parent — promoting is a per-employee action. Let's move it.
Just two things change:
-
EmployeeRownow accepts anonPromoteprop and renders its own Promote button. -
EmployeeTablesimply passespromoteEmployeeto each row.
const EmployeeRow = React.memo(function EmployeeRow({ employee, onPromote }) {
console.log('Rendering row:', employee.name);
return (
<tr>
<td>{employee.id}</td>
<td>{employee.name}</td>
<td>{employee.role}</td>
<td>
<button onClick={() => onPromote(employee.id)}>
Promote {employee.name}
</button>
</td>
</tr>
);
});
// Inside EmployeeTable, replace the <React.Fragment> block with:
{
employees.map(employee => (
<EmployeeRow
key={employee.id}
employee={employee}
onPromote={promoteEmployee}
/>
));
}
At first glance this should still work — EmployeeRow is memoized, and only one employee object reference changes per click. Let's test it.
Open the browser console. Click Promote on any employee.
Rendering row: Aditi
Rendering row: Rohan
Rendering row: Priya
Rendering row: Karan
Rendering row: Sana
Every single employee row renders again. That's not what we expected — we already have React.memo, and only one employee object changed.
Why didn't
React.memoprevent those re-renders?
The real culprit
Each row receives two props:
<EmployeeRow
key={employee.id}
employee={employee}
onPromote={promoteEmployee}
/>
employee — only one employee object changed. No problem here.
onPromote — this is where it breaks down.
Render #1
promoteEmployee ───► Function A
Render #2
promoteEmployee ───► Function B
Render #3
promoteEmployee ───► Function C
JavaScript creates a brand-new function object every time the parent component renders. To us, those functions look identical. To React, they're completely different objects.
Why React.memo can't help
React.memo performs a shallow comparison of props. Since onPromote is a different reference on every render, React assumes the component might produce different output — so it re-renders the row, every time, for every row.
This explains why wrapping a component with React.memo isn't always enough.
The component is memoized. The data didn't change. But one prop keeps getting a brand-new reference every render, and that's enough to invalidate the memoization.
Key takeaways
React.memocan only skip rendering when all of a component's props stay the same.
The employee objects stayed the same. The callback didn't — and that single changing reference was enough to force every row to render again.
Doesn't that sound familiar? In Experiment 1, we learned that useCallback exists to keep a function reference stable. At the time, that didn't seem useful. Now we're looking at a component where a changing function reference is actively breaking React.memo.
Could these two ideas be connected?
Experiment 3: The piece we were missing
We now know exactly why React.memo wasn't helping. Every employee object except the promoted one kept the same reference, but onPromote received a brand-new function reference on every parent render. Every parent render created a new promoteEmployee function reference, and React.memo's shallow comparison detected that change every time.
The fix is just one line of code: keep that callback's reference stable across renders. That's exactly what useCallback was built for.
One line changes: wrap promoteEmployee with useCallback.
import { useCallback } from 'react';
// Replace the previous implementation with:
const promoteEmployee = useCallback((id) => {
setEmployees(prev =>
prev.map(employee =>
employee.id === id
? { ...employee, role: employee.role + ' (Lead)' }
: employee
)
);
}, []);
Notice that this is the only change. EmployeeRow, the JSX, and the table structure all remain exactly the same.
The dependency array is intentionally empty because setEmployees uses a functional updater (prev => ...). React automatically provides the latest state as prev, so promoteEmployee doesn't need employees as a dependency.
Click Promote on any employee now.
This time, only the promoted employee logs a re-render. The other four employee objects never changed reference — setEmployees only creates a new object for the promoted employee, the rest of the array entries are untouched — and promoteEmployee itself never changes reference either, so React.memo's comparison finally has stable ground to stand on.
Final code
If you'd like to run the finished version yourself, here's the complete component with both React.memo and useCallback applied.
import React, { useCallback, useState } from 'react';
const initialEmployees = [
{ id: 1, name: 'Aditi', role: 'Frontend Engineer' },
{ id: 2, name: 'Rohan', role: 'Backend Engineer' },
{ id: 3, name: 'Priya', role: 'Product Manager' },
{ id: 4, name: 'Karan', role: 'DevOps Engineer' },
{ id: 5, name: 'Sana', role: 'UX Designer' },
];
// EmployeeRow receives a callback prop (onPromote).
// React.memo only skips rendering when ALL props are shallowly equal.
// useCallback keeps the onPromote function reference stable so React.memo
// can recognize that unchanged rows don't need to render again.
const EmployeeRow = React.memo(function EmployeeRow({
employee,
onPromote,
}) {
console.log('Rendering row:', employee.name);
return (
<tr>
<td>{employee.id}</td>
<td>{employee.name}</td>
<td>{employee.role}</td>
<td>
<button onClick={() => onPromote(employee.id)}>
Promote {employee.name}
</button>
</td>
</tr>
);
});
export default function EmployeeTable() {
const [employees, setEmployees] = useState(initialEmployees);
// The functional updater always receives the latest state,
// so this callback doesn't need `employees` as a dependency.
//
// Without useCallback, every parent render would create a new
// function reference, causing React.memo to re-render every row.
const promoteEmployee = useCallback((id) => {
setEmployees(prev =>
prev.map(employee =>
employee.id === id
? { ...employee, role: `${employee.role} (Lead)` }
: employee
)
);
}, []);
return (
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Role</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{employees.map(employee => (
<EmployeeRow
key={employee.id}
employee={employee}
onPromote={promoteEmployee}
/>
))}
</tbody>
</table>
);
}
So, should you wrap every function in useCallback?
Probably not.
Creating a new function during render is usually inexpensive. Using useCallback everywhere adds complexity and a small amount of overhead of its own, since React now has to store the callback and compare its dependencies on every render.
Instead, ask yourself one simple question:
Does anything actually care if this function changes?
If the answer is no, you probably don't need useCallback.
If the answer is yes — for example:
- the function is passed to a component wrapped with React.memo
- it's part of a useEffect dependency array
- it's passed to a custom hook
- a third-party library compares callback references
then useCallback is often the right choice.
A quick note about useMemo
If this reminds you of useMemo, that's because the underlying idea is similar.
useMemo memoizes values. useCallback memoizes function references. Both help React reuse the same identity across renders when their dependencies remain unchanged.
Final thoughts
When I started this experiment, I thought useCallback was a performance hook.
By the end, I realized it's really an identity hook.
It doesn't stop components from rendering. It doesn't magically make your application faster.
Its job is much smaller—and much more important. It gives React the ability to recognize that a function hasn't actually changed.
Sometimes that enables powerful optimizations. Other times, it changes absolutely nothing.
Understanding that difference is far more valuable than memorizing when to use useCallback.


Top comments (0)