useMemo and useCallback: How They Work, When to Use Them, and Best Practices
A deep dive into useMemo and useCallback: the problem they actually solve, the cases that really need memoization, common mistakes, simpler alternatives, and where they fit now that React Compiler exists.
Almost every React codebase has two camps. The first wraps every function in useCallback and every derived value in useMemo "to make it faster". The second never uses them at all, until a list starts stuttering on every keystroke. Both come from the same misunderstanding: treating these hooks as a "go faster" switch, when their job is much more specific.
This article covers what useMemo and useCallback actually do, the cases that genuinely need them, the cases where they are pointless, the most common mistakes, and how React Compiler changes the way we write this code.
The root problem: every render creates new values
A React component is just a function. Every time its state or props change, React calls that function again from the top. Everything declared inside is created again: variables, arrays, objects, and functions. For primitives like strings and numbers this does not matter, because "a" === "a". For objects and functions, it is a different story:
const a = { theme: "dark" };
const b = { theme: "dark" };
a === b; // false: same contents, different objects
const f = () => {};
const g = () => {};
f === g; // false: every arrow expression creates a new function
Object.is(a, a); // true: same referenceReact compares props, dependency arrays, and Context values with Object.is, which checks references, not contents. So an object or function created inside a component counts as "changed" on every render, even when its contents are identical. This is where all the trouble comes from: memo() that never skips a render, useEffect that runs over and over, and Context consumers that re-render for no reason.
useMemo and useCallback solve two things: skipping expensive recalculations, and keeping a value's referential identity stable between renders. The second one is the part people forget, and it is the one you need most often.
How useMemo works
useMemo takes a calculation function and a dependency array. On the first render, React runs the function and stores the result together with the dependencies. On later renders, React compares each dependency to its previous value with Object.is. If they all match, it returns the stored result without running the function again.
const value = useMemo(() => computeSomething(a, b), [a, b]);
// 1st render: runs computeSomething and stores [value, [a, b]]
// next renders: if Object.is(a, prevA) && Object.is(b, prevB), returns the stored value
// otherwise runs computeSomething again and stores the new result- The calculation must be pure: no arguments, no side effects, and it may only depend on values listed in the dependencies.
- The cache holds only the one most recent result per hook, not a history. If the dependencies go from A to B and back to A, the calculation for A runs again.
- In development with Strict Mode, React calls the calculation twice to help you find impure functions. This is expected and does not happen in production.
- React does not guarantee the cache is kept. The official docs say React may throw it away, for example when a component suspends during its initial mount. Treat
useMemoas a performance optimization, not a semantic guarantee.
How useCallback works
useCallback is useMemo specialized for functions. React does not call your function, it just stores it and hands back the same function as long as the dependencies have not changed. These two are identical:
const handleSave = useCallback((draft: Draft) => {
saveDraft(postId, draft);
}, [postId]);
// is exactly the same as
const handleSave = useMemo(() => (draft: Draft) => {
saveDraft(postId, draft);
}, [postId]);Here is the part that is often misunderstood: useCallback does not prevent the function from being created. The arrow function inside it is still created on every render, then thrown away if the dependencies did not change. So useCallback saves nothing on function creation. Its only benefit is a stable identity. If nothing cares about that identity, useCallback just adds work.
When memoization actually helps
In practice, there are only a handful of situations where these hooks pay off. If your code does not fit one of the categories below, you most likely do not need them.
1. Calculations that are genuinely expensive
The classic example is filtering and sorting a large list in a component that also has other, frequently changing state:
function ProductTable({ products, query, sortBy }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// Without useMemo, clicking a row (selectedId changes) would
// filter and sort 10,000 products again for nothing
const visible = useMemo(() => {
const q = query.toLowerCase();
return products
.filter((p) => p.name.toLowerCase().includes(q))
.sort((a, b) => compare(a, b, sortBy));
}, [products, query, sortBy]);
return <Rows items={visible} selectedId={selectedId} onSelect={setSelectedId} />;
}How expensive is "expensive"? Do not guess, measure. Wrap the calculation in console.time, and test with 4x or 6x CPU throttling in the DevTools Performance tab, because a developer laptop is far faster than the average user's phone. The React docs use roughly 1 millisecond as a rule of thumb: if the total is above that, memoization is worth considering.
console.time("filter + sort");
const visible = filterAndSort(products, query, sortBy);
console.timeEnd("filter + sort");
// filter + sort: 0.08ms -> not worth memoizing
// filter + sort: 14.2ms -> worth it, that is most of a 16ms frame2. Props for components wrapped in memo()
memo() lets a component skip re-rendering when all its props are the same as last time. But if the parent passes a new function or object on every render, that comparison always fails and memo() is useless. This is where useCallback and useMemo are truly needed:
const TodoItem = memo(function TodoItem({ todo, onToggle }: ItemProps) {
return (
<li>
<input type="checkbox" checked={todo.done} onChange={() => onToggle(todo.id)} />
{todo.title}
</li>
);
});
function TodoList({ todos }: { todos: Todo[] }) {
const [items, setItems] = useState(todos);
const [draft, setDraft] = useState("");
// Stable across renders, so typing in the input does not re-render every TodoItem
const handleToggle = useCallback((id: string) => {
setItems((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}, []);
return (
<>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<ul>
{items.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
))}
</ul>
</>
);
}Notice two things. First, useCallback here is only useful because TodoItem is wrapped in memo(). Without memo(), TodoItem re-renders whenever TodoList does, whatever its props are. Second, the updater function setItems((prev) => ...) means the callback does not depend on items, so the dependency array can be empty and the identity is stable forever.
3. Dependencies of other hooks
An object or function used as a dependency of useEffect, useMemo, or another useCallback makes that hook run on every render if its identity is not stable. For an effect, that means disconnecting and reconnecting over and over:
function ChatRoom({ roomId, serverUrl }: Props) {
// A new object every render would make the effect reconnect on every render
const options = useMemo(() => ({ roomId, serverUrl }), [roomId, serverUrl]);
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]);
}
// Even better: create the object inside the effect and depend on primitives
useEffect(() => {
const connection = createConnection({ roomId, serverUrl });
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);Look at the second solution in that example. Before reaching for useMemo or useCallback to fix a dependency, try moving the object or function inside the effect. Then the dependencies are plain primitives and you need no memoization at all.
4. Context Provider values
Every component that reads a Context re-renders when its value changes, and "changes" is checked with Object.is here too. If the value is an object literal recreated on every render of the provider, every consumer re-renders, including the ones wrapped in memo():
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = useCallback(async (email: string, password: string) => {
setUser(await api.login(email, password));
}, []);
const logout = useCallback(() => setUser(null), []);
// Without useMemo, every render of AuthProvider creates a new value object
// and every component that calls useContext(AuthContext) re-renders
const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);
return <AuthContext value={value}>{children}</AuthContext>;
}This is one of the cases most worth memoizing, because a single Context can be read by dozens of components across the app. If the value still changes too often, consider splitting it into two Contexts: one for data that changes often, and one for stable functions.
5. Functions returned from custom hooks
You do not know how the functions your custom hook returns will be used. They might end up in a useEffect dependency array, or be passed to a component wrapped in memo(). That is why functions returned from a custom hook should always be wrapped in useCallback. Libraries like React Router and TanStack Query do the same.
export function useDebouncedCallback<A extends unknown[]>(fn: (...args: A) => void, delay: number) {
const fnRef = useRef(fn);
useLayoutEffect(() => {
fnRef.current = fn; // always call the latest fn without changing identity
});
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
// Callers can safely put the returned function in their own dependency arrays
return useCallback(
(...args: A) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => fnRef.current(...args), delay);
},
[delay],
);
}When memoization is pointless
Memoization is not free. Every useMemo and useCallback adds a dependency array allocation, a comparison on every render, memory for the cache, and code that is harder to read. In the following cases you pay that cost and get nothing back:
// 1. Cheap work: the memo bookkeeping costs about as much as the work itself
const fullName = useMemo(() => `${first} ${last}`, [first, last]);
const fullName = `${first} ${last}`; // just do this
// 2. The child is not wrapped in memo(), so it re-renders anyway
const onClick = useCallback(() => setOpen(true), []);
return <Button onClick={onClick} />; // Button re-renders whenever the parent does
// 3. A dependency changes every render, so the cache never hits
const config = { limit: 10 };
const data = useMemo(() => select(items, config), [items, config]); // config is new every time- Cheap calculations: joining strings, simple math, or a
mapover a 20-item array. Comparing the dependencies costs about as much as the calculation. - The receiver does not care about identity: a function passed to a DOM element like
<button onClick>, or to a regular component withoutmemo(). That component re-renders anyway, souseCallbackchanges nothing. - A dependency changes every render: if one of the dependencies is an unstable object or function, the cache never hits. You pay for memoization and recalculate anyway.
- The component rarely re-renders: static pages, simple forms, or components that render once or twice. There are no renders to save.
One trap quietly breaks memo() all the time: children. JSX like <p>Static content</p> produces a new element object on every render, so a memo() component that receives children almost never skips a render:
const Card = memo(function Card({ children }: { children: React.ReactNode }) {
return <div className="card">{children}</div>;
});
function Parent() {
const [count, setCount] = useState(0);
// <p> is a brand-new JSX element every render, so memo(Card) never skips
return (
<Card>
<p>Static content</p>
</Card>
);
}Common mistakes
Stale closures from missing dependencies
The function inside useCallback "captures" values from the render in which it was created. If a value is left out of the dependencies, the function keeps using the old value. This is the most common bug that comes from useCallback:
// Bug: count is captured from the first render and stays 0 forever
const increment = useCallback(() => {
setCount(count + 1);
}, []);
// Fix 1: list the dependency (the function changes when count changes)
const increment = useCallback(() => {
setCount(count + 1);
}, [count]);
// Fix 2: use an updater function, no dependency needed, identity stays stable
const increment = useCallback(() => {
setCount((c) => c + 1);
}, []);Never disable the react-hooks/exhaustive-deps rule to silence this warning. If the dependency list feels "too long", that is a sign the code needs to change: use an updater function, move values inside the callback, or use useReducer, whose dispatch function is always stable.
Using useMemo for side effects or instances
useMemo runs during render, so it must not contain fetches, subscriptions, or DOM changes. And because React may discard its cache, useMemo is also not a safe place to keep an instance that must live as long as the component:
// Wrong: side effects do not belong in useMemo
const data = useMemo(() => {
fetch(`/api/items?page=${page}`).then((r) => r.json()).then(setItems);
return null;
}, [page]);
// Wrong: relying on useMemo to keep an instance alive
const socket = useMemo(() => new WebSocket(url), [url]);
// Right: one-time instances belong in useState or useRef
const [client] = useState(() => new QueryClient());Memoizing everything "just in case"
Wrapping everything in useMemo and useCallback feels safe, but it hides problems. The code gets longer, dependency arrays become a new source of bugs, and when a real performance problem shows up, it is hard to tell which memoization matters and which is decoration. A single unstable useCallback can also silently break the whole chain of memoization below it, without any error.
Try these before memoizing
Many re-render problems can be solved by restructuring components, without a single useMemo. These fixes are simpler, have no dependency arrays to get wrong, and still apply when memoization is done for you by the compiler.
Move state down
If a piece of state is only used by a small part of the page, move it into a smaller component. Everything else stops re-rendering when that state changes:
// Before: typing in the search box re-renders <ExpensiveChart />
function Dashboard() {
const [query, setQuery] = useState("");
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<SearchResults query={query} />
<ExpensiveChart />
</>
);
}
// After: the state lives in a smaller component, no memoization needed
function Dashboard() {
return (
<>
<Search />
<ExpensiveChart />
</>
);
}
function Search() {
const [query, setQuery] = useState("");
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<SearchResults query={query} />
</>
);
}Lift content up with children
This is the flip side of the children trap above: when a component with frequently changing state receives children from its parent, React knows those elements did not change and skips rendering them. The pattern is great for wrappers that listen to scroll, resize, or mouse position:
function ScrollTracker({ children }: { children: React.ReactNode }) {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const onScroll = () => setScrollY(window.scrollY);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
// children were created by the parent, so they are the same elements
// on every scroll and React skips re-rendering them
return <div data-scrolled={scrollY > 0}>{children}</div>;
}
<ScrollTracker>
<ArticleBody />
</ScrollTracker>Use useEffectEvent for logic inside effects
Often useCallback is used only so a function can be called from an effect without making that effect re-run. Since React 19.2 there is a dedicated hook for this. useEffectEvent always reads the latest props and state, but does not need to (and must not) go in the dependency array:
import { useEffect, useEffectEvent } from "react";
function ChatRoom({ roomId, theme }: Props) {
// Reads the latest theme but is not a dependency of the effect
const onConnected = useEffectEvent(() => {
showNotification("Connected!", theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]); // changing theme no longer reconnects
}React Compiler changes the rules
React Compiler has been stable since version 1.0 and can be enabled directly in Next.js 16. It analyzes your components at build time and inserts memoization automatically: derived values, functions, and even JSX elements. The result is more fine-grained than what we usually write by hand, because the compiler can memoize code inside conditions and even after an early return, which is impossible with hooks.
npm install -D babel-plugin-react-compilerimport type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
};
export default nextConfig;// With React Compiler, write plain code
function ProductTable({ products, query, sortBy }: Props) {
const visible = products
.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => compare(a, b, sortBy));
const handleSelect = (id: string) => setSelectedId(id);
return <Rows items={visible} onSelect={handleSelect} />;
}
// The compiler caches visible, handleSelect and the <Rows> element automatically,
// with finer granularity than hand-written useMemo/useCallbackWith the compiler on, the guidance becomes simple: write plain code without useMemo and useCallback for new code. There is no need to rush to delete existing hooks, since the compiler works safely around them. Remove them one at a time and test as you go, because removing them can change the compiler's output and the behavior of effects that depend on those values.
useMemo and useCallback remain useful as escape hatches. For example, when a value is an effect dependency and you want explicit control over when that effect runs, or when the compiler skips a component because its code breaks the Rules of React. The compiler can only do its job when your components are pure, so rules like not mutating props and state matter more than ever.
How to prove your memoization works
- Open React DevTools, go to the Profiler tab, and enable "Record why each component rendered while profiling".
- Record the interaction that feels slow, such as typing in an input or clicking a table row.
- Look for components that render slowly or often. The right-hand panel tells you why, for example "Props changed: onToggle". That means the
onTogglefunction is not stable. - Apply the fix, record again, and compare. If the numbers did not change, remove the memoization again.
- For accurate numbers, repeat the measurement on a production build, because the development build is much slower and runs Strict Mode.
With React Compiler enabled, React DevTools shows a Memo ✨ badge on components the compiler has optimized. A component without the badge was skipped by the compiler and is worth checking.
Best practices summary
- Remember that the main purpose of these hooks is a stable referential identity, not "making code faster" in general.
- Use
useMemofor calculations that are measurably expensive (about 1 ms or more with CPU throttling), not based on a hunch. - Use
useCallbackonly when the function is passed to amemo()component, used as a dependency of another hook, or returned from a custom hook. - Always memoize object values passed to a Context Provider.
- Fill dependency arrays honestly and let
eslint-plugin-react-hooksguard them. Reduce dependencies with updater functions anduseReducer, not by disabling the lint rule. - Try restructuring first: move state down, lift content up through
children, and create objects inside effects. - Never use
useMemofor side effects or to hold instances. UseuseEffect,useState, oruseRefinstead. - For new projects on React 19 and Next.js 16, enable React Compiler and write plain code. Reach for the manual hooks only as escape hatches.
- Measure before and after with the React Profiler. Memoization that is not proven to help should be removed.
Memoization is a tool for a problem you have proven exists, not a habit to paste into every component.
In the end, useMemo and useCallback are not complicated hooks. What is tricky is recognizing when a reference actually matters. Once you understand that React compares by reference, the decision to use these hooks, or not, usually becomes obvious.