In short
Every render creates new functions, and a new function is never equal to the old one, so memo children re-render and Effects re-run. useCallback returns the same function until a dependency changes (compared with Object.is). Use functional state updates to keep its dependency list short, and with React Compiler on, write plain code and keep useCallback as an escape hatch.
useCallback is the sibling of useMemo, and it confuses people for a similar reason. A team wraps a child in memo, sees no improvement in the profiler, and adds useCallback everywhere hoping something sticks. The missing piece is almost always the same: what React considers "the same function".
This is the second post in a short series. The first one covered useMemo. The short version of the difference: useMemo caches a value, useCallback caches a function. Below we look at what that means, when it helps, the stale-closure bug it can cause, and how the React Compiler (which this site uses) changes the advice.
The problem in plain words
When a component re-renders, React runs its function body again. Every function () {} or () => {} written in that body creates a new function object. It does the same thing as last time, but it is a different object.
Usually that doesn't matter. It matters when something downstream compares by reference:
- A child wrapped in
memo.memocompares each prop with its previous value usingObject.is. Two different function objects are never equal, so a newonClickon every render means the child re-renders every time andmemodoes nothing. - An Effect (or another Hook) that lists the function as a dependency. A new function means "something changed", so the Effect runs again.
Before: SaveButton is memoized, but it still re-renders whenever Editor does, for example on a theme change.
import { memo } from "react";
const SaveButton = memo(function SaveButton({ onClick }: { onClick: () => void }) {
return <button onClick={onClick}>Save</button>;
});
export function Editor({ docId, theme }: { docId: string; theme: string }) {
// A brand-new function on every render.
const handleSave = () => {
console.log("saving", docId);
};
return (
<div className={theme}>
<SaveButton onClick={handleSave} />
</div>
);
}
After: handleSave stays the same function until docId changes, so a theme change no longer re-renders SaveButton.
import { memo, useCallback } from "react";
const SaveButton = memo(function SaveButton({ onClick }: { onClick: () => void }) {
return <button onClick={onClick}>Save</button>;
});
export function Editor({ docId, theme }: { docId: string; theme: string }) {
// The same function until docId changes.
const handleSave = useCallback(() => {
console.log("saving", docId);
}, [docId]);
return (
<div className={theme}>
<SaveButton onClick={handleSave} />
</div>
);
}
How it works, step by step
const cachedFn = useCallback(fn, dependencies);
fn is the function you want to keep. dependencies is an inline array of every reactive value it reads: props, state, and variables declared in the component body.
- First render. React returns
fn, the function you passed, and stores it with the dependencies. It does not call it. - Every later render. You pass a new
fn(the body ran again, so the arrow function was created again). React compares each dependency with its previous value usingObject.is. - All the same? React returns the function it stored earlier and ignores the new one.
- Anything different? React returns the new function and stores it for next time.
If that sounds like useMemo, it is. The React docs describe it with this simplified implementation:
// Simplified implementation (inside React)
function useCallback(fn, dependencies) {
return useMemo(() => fn, dependencies);
}
useMemo calls your function and caches what it returns. useCallback caches the function itself.
The parent re-renders the same number of times in both lanes; only the lane that keeps the same function lets the memo child skip.
A complete example
This is the case the React docs use: a product page passes a submit handler to a form wrapped in memo. The form should not re-render when only the theme changes.
import { memo, useCallback } from "react";
type OrderDetails = { street: string; city: string; zip: string };
function post(url: string, data: object) {
return fetch(url, { method: "POST", body: JSON.stringify(data) });
}
const ShippingForm = memo(function ShippingForm({
onSubmit,
}: {
onSubmit: (details: OrderDetails) => void;
}) {
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
onSubmit({
street: String(data.get("street")),
city: String(data.get("city")),
zip: String(data.get("zip")),
});
}}
>
<input name="street" />
<input name="city" />
<input name="zip" />
<button type="submit">Submit</button>
</form>
);
});
export function ProductPage({
productId,
referrer,
theme,
}: {
productId: string;
referrer: string;
theme: string;
}) {
const handleSubmit = useCallback(
(orderDetails: OrderDetails) => {
post("/product/" + productId + "/buy", { referrer, orderDetails });
},
[productId, referrer],
);
return (
<div className={theme}>
<ShippingForm onSubmit={handleSubmit} />
</div>
);
}
Both halves are needed. memo without useCallback sees a new onSubmit each time. useCallback without memo keeps the function stable, but nobody checks, so the form re-renders anyway.
When useCallback actually helps
The React docs name two cases:
- The function is passed as a prop to a component wrapped in
memo, likeShippingFormabove. - The function is a dependency of another Hook, such as
useEffectoruseMemo.
They also recommend wrapping the functions a custom Hook returns in useCallback, so the components using the Hook can optimize their own code if they need to:
import { useCallback, useState } from "react";
export function useCounter(step: number) {
const [count, setCount] = useState(0);
const increment = useCallback(() => setCount((c) => c + step), [step]);
const reset = useCallback(() => setCount(0), []);
return { count, increment, reset };
}
Anywhere else it gives you nothing back. The docs say there's no significant harm either, but for an Effect there is usually a simpler fix: move the function inside the Effect, so it isn't a dependency at all.
import { useEffect } from "react";
declare function createConnection(options: { serverUrl: string; roomId: string }): {
connect(): void;
disconnect(): void;
};
export function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
function createOptions() {
return { serverUrl: "https://localhost:1234", roomId };
}
const connection = createConnection(createOptions());
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to {roomId}</h1>;
}
Stale closures and functional updates
A function "closes over" the values from the render that created it. When useCallback hands back a function from an earlier render, that function still sees that render's props and state. With a correct dependency list that is fine: when a value it reads changes, you get a new function.
The bug starts when someone removes a dependency to keep the function stable. Say a callback adds a todo with setTodos([...todos, newTodo]) and lists [todos]. It changes after every added todo, so a memo child re-renders anyway. Drop todos from the list and the function stays the same, but it also keeps the todos array from the first render forever. Every new todo replaces the previous ones. The react-hooks/exhaustive-deps lint rule flags this, which is a good reason to keep it on.
The fix is to stop needing the dependency. Pass an updater function to the setter, and React gives you the latest state:
import { useCallback, useState } from "react";
type Todo = { id: number; text: string };
let nextId = 0;
export function useTodosBefore() {
const [todos, setTodos] = useState<Todo[]>([]);
// Changes every time todos changes.
const handleAddTodo = useCallback(
(text: string) => {
setTodos([...todos, { id: nextId++, text }]);
},
[todos],
);
return { todos, handleAddTodo };
}
export function useTodos() {
const [todos, setTodos] = useState<Todo[]>([]);
// Never changes: the updater receives the latest todos.
const handleAddTodo = useCallback((text: string) => {
setTodos((prev) => [...prev, { id: nextId++, text }]);
}, []);
return { todos, handleAddTodo };
}
The docs put it simply: you'll usually want memoized functions to have as few dependencies as possible.
A related tool is useEffectEvent, added in React 19.2 (October 2025). It lets an Effect call a function that always sees the latest props and state without making it a dependency. It is not a replacement for useCallback: its identity changes on every render on purpose, and it must only be called from inside Effects, never passed to other components. For handlers you pass to children, the docs point back to a plain function or useCallback.
Where the React Compiler fits
React Compiler memoizes values and functions in components and Hooks at build time, and applies the equivalent of memo to components. The docs show it handling a case people often get wrong by hand: even with handleClick wrapped in useCallback, writing onClick={() => handleClick(item)} creates a new arrow function on every render and breaks the child's memoization. The compiler optimizes that correctly with or without the arrow function.
This site turns the compiler on with reactCompiler: true in next.config.ts (the useMemo post shows the setup). The React team's guidance is the same for both Hooks:
- New code: rely on the compiler, and use
useCallbackoruseMemowhere you need precise control. - Existing code: leave the memoization in place, or test carefully before removing it, because removing it can change the compiler's output.
- Escape hatch: a common reason to keep it is a function used as an Effect dependency, so the Effect doesn't fire again when nothing meaningful changed.
Pitfalls and when not to use it
- It doesn't make anything faster on its own. Creating a function is cheap. The win only comes from what you skip downstream: a
memochild's render or an Effect's re-run. - One unstable prop undoes it. If the parent also passes
style={{...}}or an inline array to the samememochild, the child re-renders anyway. - Don't hide dependencies. Removing one to keep identity stable creates a stale closure. Use a functional update, or move the logic into the Effect.
- Don't rely on it for correctness. React may throw the cached function away: in development when you edit the file, and in development and production if the component suspends during its first mount. If code breaks when the function changes, use state or a ref.
- Top level only. It's a Hook, so no calls inside loops or conditions. Extract a child component instead.
In practice
- Add
useCallbackonly for functions passed tomemochildren, used as Hook dependencies, or returned from a custom Hook. - Pair it with
memoon the child, or it does nothing. - Keep the dependency list short with functional state updates, and keep
exhaustive-depson. - For Effects, try moving the function inside the Effect first.
- With React Compiler on, write plain functions and add
useCallbackonly where you need control. - Keep existing
useCallbackcalls when you turn the compiler on, unless you test before removing them.
For a side-by-side guide, read useMemo vs useCallback: when to use them (and when not to). And if a React or Next.js app of yours re-renders more than it should, tell us about it.