Functions in JavaScript are objects compared by reference, so a component recreates every function it defines on each render ā even when the logic is identical. The useCallback hook lets you lock a function to a stable reference, which matters most when that function is passed down to a child optimized with React.memo.
1What is Callback Logic?
While useMemo caches the result of a calculation, useCallback caches an entire function definition instead ā it returns the same function instance across renders as long as its dependencies haven't changed. This matters because functions passed as props to memoized child components can otherwise force those children to re-render unnecessarily, just because a new function reference was created.
// useCallback: Stable function referencesCaching Logic
Saving instructions, not just answers.
2Functions are Objects
In JavaScript, functions are objects, and objects are compared by memory reference rather than by their contents. Two arrow functions with identical code are still considered different values (f1 === f2 is false) if they were created separately. Since a component's function body reruns on every render, any function defined inside it is a brand-new object each time, even though the logic never changed.
const handleAdd = () => setCount(c => c + 1);
// Render 1: handleAdd_v1
// Render 2: handleAdd_v2Function Equality
0x123 !== 0x456
3Re-renders and Functions
Every time a component re-renders, React runs the entire function body again from top to bottom, including any inline function definitions like event handlers. That means a handler such as handleClick isn't reused between renders ā it's thrown away and rebuilt as a brand-new function object in a new memory location every single time, even if its logic hasn't changed at all.
import { useCallback } from 'react';
const handleAdd = useCallback(() => {
setCount(c => c + 1);
}, []);Memory Churn
Functions deleted and remade constantly.
4The useCallback Hook
useCallback(fn, deps) stops this churn by telling React to keep the exact function you pass it in memory. As long as the values in the dependency array haven't changed between renders, React hands back the identical cached function instead of creating a new one, giving that function a stable reference across re-renders.
const Child = React.memo(({ onClick }) => ...);
// Without useCallback, Child re-renders every time!5The Dependency Array
Just like useMemo and useEffect, useCallback takes a dependency array as its second argument. Any state or prop the function reads internally ā like userId ā must be listed there. When one of those dependencies changes between renders, React discards the cached function and creates a fresh one so the function's closure can see the updated value.
// With useCallback, onClick is stable.
// Child skips re-render! ā
Dependency Tracking
Stay synchronized with State
6Stale Closures
If a memoized function reads a variable like text but that variable is missing from the dependency array, you get a Stale Closure: React keeps returning the same cached function, but that function is trapped using the value text had on the very first render, ignoring every update that happens afterward.
const handleSave = useCallback(() => {
saveData(id, text);
}, [id, text]); // Track data used insideā ļø Stale Closure Bug
Function trapped in the past.
7Combining with React.memo
Stable function references matter most when passing a function down as a prop to a child wrapped in React.memo. React.memo is a wrapper that tells a component: don't re-render unless your props have actually changed, comparing each prop by reference before deciding whether to skip the re-render.
/* Callback Lab: React.memo vs Unstable Functions Rendered */React.memo Shield
Blocking unnecessary re-renders.
8How React.memo Works
Normally, when a parent component re-renders, React re-renders every child underneath it too. But a child wrapped in React.memo is different: React first checks whether its props are identical to the previous render, and if they are, it skips re-rendering that child entirely, saving real CPU time.
// Simple button? Regular function is fine.
// Complex graph/list item? useCallback is king.9Why React.memo Fails
If you pass a normal, un-memoized function to a React.memo child, the optimization silently breaks. The parent recreates that function on every render, giving it a new memory address each time ā so React.memo's prop comparison sees a 'changed' onClick prop and re-renders the child anyway, even though the function's behavior never actually changed.
const inc = useCallback(() => set(p => p + 1), []); // No deps needed!Optimization Broken
Unstable functions destroy React.memo
10The Fix: useCallback
Wrapping onClick in useCallback fixes this: it locks the function's memory address, so the parent passes the exact same function reference to HeavyChild on every render. React.memo compares that reference, sees it's identical to last time, and successfully skips re-rendering the child ā restoring the performance win.
/* Next: Complex State (useReducer) */Optimization Restored
Stable callbacks save the day.
11Feeding a Stable Callback Into useEffect
The same stability trick solves a second problem: when a function is used inside a useEffect and also needs to be listed in its dependency array, an un-memoized function makes the effect re-run on every render. Wrapping that function in useCallback satisfies the exhaustive-deps rule without the effect firing constantly.
const fetchData = useCallback(() => {
api.get(`/items/${id}`).then(setItems);
}, [id]);
useEffect(() => { fetchData(); }, [fetchData]);Stable Dependency
Effect re-runs only when 'id' truly changes.
12Step-by-Step Breakdown
What is Callback Logic?. Welcome to Callback Logic with useCallback. While useMemo is used to cache the *result* of a calculation, useCallback is used to cache an entire *function definition*. This sounds subtle, but it's the key to preventing massive, cascading re-renders in complex React applications.
Functions are Objects. To understand why useCallback exists, you must remember a core JavaScript rule: Functions are Objects. And just like objects, functions are compared by their memory reference, not by what they look like. Even if two functions have the exact same code inside them, JavaScript considers them to be completely different if they were created at different times.
Re-renders and Functions. Every time a React component re-renders, it runs all the code inside it from top to bottom. This means any function you define inside your component gets recreated from scratch on every single render. You aren't reusing the old function; you are throwing it away and creating a brand new function object in a brand new memory location.
The useCallback Hook. useCallback stops this memory churn. By wrapping your function in useCallback(fn, deps), you tell React: 'Hey, keep this exact function in memory. Next time you render, don't create a new one, just give me the one you saved.' It locks the function to a stable memory address.
What does useCallback return to you?
- āThe returned value of the function (e.g., 5)
- āThe memoized function itself (e.g., () => 5)
The Dependency Array. Just like useMemo and useEffect, useCallback requires a dependency array. This array tells React when it MUST throw away the cached function and create a new one. If your function uses a state variable like userId inside its logic, you must put userId in the dependency array. If userId changes, the function is recreated so it can 'see' the new userId.
Stale Closures. What happens if you use text inside the function, but FORGET to put text in the dependency array? You create a 'Stale Closure'. React locks the function into memory, but that function is trapped in the past. It will forever use the initial, outdated value of text from the first render, ignoring all future updates.
If count is 5, but a memoized function inside your component still thinks count is 0, what likely happened?
- āReact has a bug
- āStale Closure: 'count' was left out of the dependency array
Combining with React.memo. So, why do we want stable memory addresses for functions? It's primarily used when passing functions down to child components that have been optimized with React.memo. React.memo is a wrapper that tells a component: 'Do not re-render unless your props actually change.'
How React.memo Works. When a parent component re-renders, it normally forces all its children to re-render too. But if a child is wrapped in React.memo, React stops and checks the child's props. If the props are identical to the last render, React skips re-rendering the child entirely, saving massive amounts of CPU time.
Why React.memo Fails. But here is the catch: If you pass a normal, un-memoized function to that React.memo child, the optimization breaks entirely. Because the parent recreates the function on every render, the function gets a new memory address. React.memo checks the onClick prop, sees a new memory address, thinks the prop changed, and forces the child to re-render anyway!
The Fix: useCallback. This is where useCallback comes to the rescue. By wrapping onClick in useCallback, you lock the function's memory address. Now, when the parent re-renders, it passes the EXACT same function reference down to HeavyChild. React.memo checks the prop, sees the memory address is identical, and successfully skips the re-render.
If a child component is wrapped in React.memo, but it still re-renders every time the parent renders, what is the most likely cause?
- āThe CSS styles changed
- āThe parent is passing down an un-memoized function prop
Pro Tip: Functional Updates. Sometimes, adding a state variable to the dependency array causes the callback to recreate too often. You can avoid this by using 'functional updates'. Instead of setCount(count + 1) which requires count in the dependency array, use setCount(prevCount => prevCount + 1). This allows you to update state without needing the state variable in your dependencies!
Feeding a Stable Callback Into useEffect. The exact same stability trick solves a second problem: when a function is used inside a useEffect and also needs to be listed in its dependency array, an un-memoized function makes the effect re-run on every render. Wrapping that function in useCallback lets you satisfy the exhaustive-deps rule without the effect firing constantly.
Mastery Achieved. Callback mastery achieved! You now understand that functions in JS are objects with memory addresses. You know how to use useCallback to stabilize those addresses, how to avoid Stale Closures with dependency arrays, and how to combine useCallback with React.memo to create ultra-fast, optimized React architectures.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Stable Handlers for Keyboard-Accessible Custom Widgets
When a custom interactive widget (like a combobox or slider) passes a keydown handler down to a child, wrapping that handler in useCallback prevents the child from being unnecessarily recreated, which could otherwise reset focus state or interrupt keyboard interaction.
2Memoization Alone Does Not Create Accessible Behavior
Wrapping a click handler in useCallback only stabilizes its reference between renders ā it does nothing for accessibility on its own. The element it's attached to still needs the correct role, keyboard support, and visible focus indication.
SEO Implications
- 1
useCallback Has No Direct Effect on Rendered Markup
Because useCallback only stabilizes function references between renders, it changes nothing about the HTML a server-rendered page outputs; search engines see identical markup whether or not handlers are memoized.
- 2
Indirect Benefit Through Faster Client-Side Interactivity
By preventing unnecessary re-renders of memoized child components, useCallback can reduce the rendering work a page does after hydration, which can help pages that rely on client-side rendering become interactive sooner for crawlers that execute JavaScript.
Best Practices
Only Memoize Functions Passed to Memoized Children or Other Hooks
useCallback has overhead of its own ā creating a closure and diffing the dependency array on every render. Wrapping a handler that's only ever used inline in JSX, like a simple onClick, adds cost without any benefit.
Keep the Dependency Array Exhaustive
Every value from render scope that the callback reads ā state, props, or other variables ā must be listed in its dependency array, or the memoized function will silently keep operating on stale data.
Frequent Bugs
A child wrapped in React.memo keeps re-rendering even though its props look unchanged.
The parent is very likely passing a fresh, un-memoized function as a prop on every render. Wrap that function in useCallback with the correct dependency array so its reference stays stable.
Real-World Examples
Stabilizing a Handler Passed to a Memoized List Item
A todo list renders hundreds of React.memo-wrapped TodoItem components; wrapping the shared onToggle handler in useCallback keeps its reference stable so unrelated state changes in the parent don't force every item to re-render.
const handleToggle = useCallback((id) => {
dispatch({ type: 'TOGGLE', id });
}, [dispatch]);