useCallback Hook Guide: Memoizing Functions Correctly
useCallback is one of React's most misunderstood Hooks. Developers often add it everywhere, hoping to boost performance, only to find their components are just as slow—or slower. The problem? useCallback isn't a magic performance pill. It's a surgical tool that solves a specific problem. Use it correctly, and you'll prevent unnecessary re-renders. Use it wrong, and you'll add overhead without benefit. This guide cuts through the confusion and shows you exactly when useCallback matters.
Table of Contents
- What is useCallback and Why It Exists
- The Problem It Solves
- How useCallback Works
- useCallback vs useMemo
- When to Actually Use useCallback
- Common Mistakes
- Combining useCallback with memo()
- Real-World Patterns
- The React Compiler Change
- FAQ
What is useCallback and Why It Exists
The Basic Concept
useCallback memoizes a function, meaning it keeps the same function reference across re-renders (unless dependencies change). Instead of creating a new function every time the component renders, you get the old one back:
import { useCallback } from 'react';
export function MyComponent() {
// Without useCallback: new function created on every render
const handleClick = () => {
console.log('Clicked');
};
// With useCallback: same function returned unless dependencies change
const memoizedClick = useCallback(() => {
console.log('Clicked');
}, []);
return <button onClick={memoizedClick}>Click me</button>;
}
import { useCallback } from 'react';
export function MyComponent() {
// Without useCallback: new function created on every render
const handleClick = () => {
console.log('Clicked');
};
// With useCallback: same function returned unless dependencies change
const memoizedClick = useCallback(() => {
console.log('Clicked');
}, []);
return <button onClick={memoizedClick}>Click me</button>;
}
The Syntax
useCallback takes two arguments:
- The function to memoize: Your callback function
- The dependency array: When dependencies change, a new function is created
const memoizedCallback = useCallback(
() => {
// Function body
},
[dependency1, dependency2] // Dependencies
);
The Problem It Solves
Functions Are Re-created Every Render
In JavaScript, functions are objects. Each time you define a function in a component, you create a new object, even if the code is identical:
export function Parent() {
// NEW function object created on every render
const handleButtonClick = () => {
console.log('Button clicked');
};
return <Child onClick={handleButtonClick} />;
}
interface ChildProps {
onClick: () => void;
}
export function Child({ onClick }: ChildProps) {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
}
export function Parent() {
const handleButtonClick = () => {
console.log('Button clicked');
};
return <Child onClick={handleButtonClick} />;
}
export function Child({ onClick }) {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
}
What happens:
- Parent renders → creates new
handleButtonClickfunction - Parent passes it to Child via props
- Even though Child's code is the same, the
onClickprop is a different object - If Child is wrapped with
memo(), it sees a new prop value and re-renders anyway
This breaks the optimization benefit of memo().
How useCallback Works
With useCallback
Wrap your function with useCallback to keep the same reference:
import { useCallback } from 'react';
export function Parent() {
// Same function reference returned every render (unless dependencies change)
const handleButtonClick = useCallback(() => {
console.log('Button clicked');
}, []);
return <Child onClick={handleButtonClick} />;
}
interface ChildProps {
onClick: () => void;
}
export function Child({ onClick }: ChildProps) {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
}
export function Parent() {
const handleButtonClick = useCallback(() => {
console.log('Button clicked');
}, []);
return <Child onClick={handleButtonClick} />;
}
export function Child({ onClick }) {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
}
What happens:
- Parent renders →
useCallbackreturns the memoized function - Parent passes it to Child via props
- The
onClickprop is the same object as before - Child's memoization (if using
memo()) sees the same prop value and doesn't re-render
Functions with Dependencies
If your callback uses values that change, include them in the dependency array:
import { useCallback, useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
// New callback created when count changes
const increment = useCallback(() => {
setCount(count + 1);
}, [count]); // count is a dependency
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
export function Counter() {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount(count + 1);
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
useCallback vs useMemo
The Key Difference
| Hook | Purpose | Returns |
|---|---|---|
| useCallback | Memoizes a function | The function itself |
| useMemo | Memoizes a value | The value (could be anything) |
Side-by-Side Comparison
useCallback:
// Memoize the function itself
const memoizedFunc = useCallback(() => {
return doSomething();
}, [dependencies]);
useMemo:
// Memoize the result of calling the function
const memoizedValue = useMemo(() => {
return doSomething();
}, [dependencies]);
In Practice
You can use useMemo to replace useCallback:
// These are equivalent:
// Option 1: useCallback (purpose-built for functions)
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
// Option 2: useMemo (more general, but less clear intent)
const handleClick = useMemo(() => {
return () => {
setCount(c => c + 1);
};
}, []);
Recommendation: Use useCallback when memoizing functions—it's clearer and more concise.
When to Actually Use useCallback
✅ USE useCallback When
1. Passing callback to memoized child component
import { memo, useCallback } from 'react';
export function Parent() {
const [count, setCount] = useState(0);
// ✅ useCallback makes sense here
const handleDelete = useCallback((id: string) => {
// Call API to delete
setCount(c => c - 1);
}, []);
// Child is memoized, so stable callback reference matters
return <MemoizedList onDelete={handleDelete} />;
}
interface ListProps {
onDelete: (id: string) => void;
}
const MemoizedList = memo(function List({ onDelete }: ListProps) {
return <div>List items...</div>;
});
2. Using callback as dependency in useEffect
import { useCallback, useEffect } from 'react';
export function DataFetcher() {
// ✅ useCallback prevents infinite useEffect loops
const fetchData = useCallback(async () => {
const response = await fetch('/api/data');
return response.json();
}, []);
useEffect(() => {
fetchData();
}, [fetchData]); // Stable callback prevents re-runs
return <div>Data...</div>;
}
3. Using callback in custom Hooks dependencies
const memoizedCallback = useCallback(() => {
// ...
}, [dep1, dep2]);
// useCallback stabilizes the function for other Hooks
useEffect(() => {
// This won't run on every render
}, [memoizedCallback]);
❌ DON'T USE useCallback When
1. Just storing a simple callback (no memoized children)
export function Form() {
// ❌ WRONG: useCallback adds overhead without benefit
const handleSubmit = useCallback((e: React.FormEvent) => {
console.log('Submitted');
}, []);
return <form onSubmit={handleSubmit}>...</form>;
}
// Better: just use a regular function
export function Form() {
// ✅ CORRECT: no memoization needed
const handleSubmit = (e: React.FormEvent) => {
console.log('Submitted');
};
return <form onSubmit={handleSubmit}>...</form>;
}
2. Child component isn't memoized
export function Parent() {
// ❌ WRONG: useCallback doesn't help if child always re-renders
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
// Child re-renders whenever Parent renders anyway
return <Child onClick={handleClick} />;
}
function Child({ onClick }) {
// Not memoized, so parent's useCallback provides no benefit
return <button onClick={onClick}>Click</button>;
}
3. Callback is used only in inline event handlers
export function Counter() {
const [count, setCount] = useState(0);
// ❌ WRONG: useCallback not needed for inline handlers
const increment = useCallback(() => {
setCount(c => c + 1);
}, []);
return <button onClick={increment}>Increment</button>;
// ✅ Better: just inline it
return <button onClick={() => setCount(c => c + 1)}>Increment</button>;
}
Common Mistakes
Mistake 1: Forgetting Dependencies
import { useCallback, useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
// ❌ WRONG: count is used but not in dependency array
const increment = useCallback(() => {
console.log(`Current count: ${count}`);
setCount(count + 1); // Always sets to 1 (stale count)
}, []); // Missing [count]!
return (
<button onClick={increment}>
Count: {count}
</button>
);
}
The problem: The callback closure captures the initial count value (0). It never sees updated values.
The fix:
export function Counter() {
const [count, setCount] = useState(0);
// ✅ CORRECT: count in dependency array
const increment = useCallback(() => {
console.log(`Current count: ${count}`);
setCount(count + 1);
}, [count]); // count is a dependency
return <button onClick={increment}>Count: {count}</button>;
}
Mistake 2: Adding Too Many Dependencies
export function SearchResults() {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState({});
const [limit, setLimit] = useState(10);
// ❌ PROBLEMATIC: Too many dependencies = callback recreated often
const search = useCallback(async () => {
const results = await fetch(
`/api/search?q=${query}&filters=${JSON.stringify(filters)}&limit=${limit}`
);
return results.json();
}, [query, filters, limit]); // Every state change recreates function
// This defeats the purpose of useCallback!
}
When dependencies change frequently, useCallback provides little benefit. This is normal—useCallback isn't always beneficial.
Mistake 3: Using useCallback Without memo()
export function Parent() {
const [count, setCount] = useState(0);
// ❌ WRONG: useCallback without memo() on child
const handleClick = useCallback(() => {
console.log('Clicked');
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Update parent: {count}
</button>
{/* Child is NOT memoized, so it re-renders anyway */}
<Child onClick={handleClick} />
</div>
);
}
function Child({ onClick }) {
// Not wrapped with memo(), so useCallback is wasted
console.log('Child rendered');
return <button onClick={onClick}>Child button</button>;
}
// ✅ CORRECT: memoize the child
const Child = memo(function Child({ onClick }) {
console.log('Child rendered');
return <button onClick={onClick}>Child button</button>;
});
Combining useCallback with memo()
The Pattern
useCallback only works when the child component is memoized:
import { memo, useCallback, useState } from 'react';
export function Parent() {
const [count, setCount] = useState(0);
// Step 1: Memoize the callback
const handleDelete = useCallback((id: string) => {
console.log(`Deleting ${id}`);
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* Step 2: Memoize the child */}
<MemoizedDeleteButton onDelete={handleDelete} />
</div>
);
}
interface DeleteButtonProps {
onDelete: (id: string) => void;
}
// ✅ Both memo() and useCallback together
const MemoizedDeleteButton = memo(function DeleteButton({ onDelete }: DeleteButtonProps) {
console.log('DeleteButton rendered');
return <button onClick={() => onDelete('item-1')}>Delete</button>;
});
export function Parent() {
const [count, setCount] = useState(0);
const handleDelete = useCallback((id) => {
console.log(`Deleting ${id}`);
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoizedDeleteButton onDelete={handleDelete} />
</div>
);
}
const MemoizedDeleteButton = memo(function DeleteButton({ onDelete }) {
console.log('DeleteButton rendered');
return <button onClick={() => onDelete('item-1')}>Delete</button>;
});
Both memo() and useCallback are required for this pattern to work.
Real-World Patterns
Pattern 1: Event Handler in List Items
A common scenario: rendering a list of items, each with a delete button:
import { memo, useCallback, useState } from 'react';
interface Item {
id: string;
name: string;
}
export function ItemList() {
const [items, setItems] = useState<Item[]>([
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
]);
// ✅ Memoized callback with item id
const handleDelete = useCallback((itemId: string) => {
setItems(prev => prev.filter(item => item.id !== itemId));
}, []);
return (
<ul>
{items.map(item => (
<MemoizedListItem
key={item.id}
item={item}
onDelete={handleDelete}
/>
))}
</ul>
);
}
interface ListItemProps {
item: Item;
onDelete: (id: string) => void;
}
// ✅ Memoized child component
const MemoizedListItem = memo(function ListItem({ item, onDelete }: ListItemProps) {
console.log(`ListItem ${item.id} rendered`);
return (
<li>
{item.name}
<button onClick={() => onDelete(item.id)}>Delete</button>
</li>
);
});
Benefit: Each ListItem only re-renders when its own item prop changes, not when other items are deleted.
Pattern 2: API Call in useEffect
Stabilizing API call functions:
import { useCallback, useEffect, useState } from 'react';
interface User {
id: string;
name: string;
email: string;
}
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// ✅ Memoized fetch function
const fetchUser = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch user');
} finally {
setIsLoading(false);
}
}, [userId]); // Re-create function when userId changes
useEffect(() => {
fetchUser();
}, [fetchUser]); // Stable dependency for useEffect
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}
Benefit: useCallback with userId dependency ensures useEffect runs only when userId changes, not on every render.
Pattern 3: Form Handling with Validation
Complex forms with async validation:
import { useCallback, useState } from 'react';
export function RegistrationForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [emailError, setEmailError] = useState('');
const [isValidating, setIsValidating] = useState(false);
// ✅ Memoized validation callback
const validateEmail = useCallback(async (value: string) => {
setIsValidating(true);
try {
const response = await fetch('/api/check-email', {
method: 'POST',
body: JSON.stringify({ email: value }),
});
const { available } = await response.json();
setEmailError(available ? '' : 'Email already registered');
} finally {
setIsValidating(false);
}
}, []);
return (
<form>
<input
value={email}
onChange={(e) => {
setEmail(e.target.value);
validateEmail(e.target.value); // Stable function reference
}}
disabled={isValidating}
/>
{emailError && <p>{emailError}</p>}
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Register</button>
</form>
);
}
The React Compiler Change
What's Happening
React is building a compiler that automatically adds optimizations like useCallback, useMemo, and memo(). Once stable, you might not need to manually add useCallback anymore.
From React Key Concepts:
"Once the React compiler is stable, it will very likely be a standard tool that's part of every React project's build process. Therefore, you won't have to use memo(), useMemo(), or useCallback() manually in your code anymore."
What This Means Today
- For now: Still manually use
useCallbackin production code - Soon: The compiler will handle it for you
- Implication: Don't over-optimize prematurely—focus on correctness first
FAQ
Q: Should I wrap all my callbacks with useCallback?
A: No. Only use it when:
- The callback is passed to a memoized child component, OR
- The callback is used as a dependency in
useEffector other Hooks
Otherwise, you're adding overhead without benefit.
Q: Does useCallback improve performance if the child component isn't memoized?
A: No. If the child always re-renders anyway, the stable callback reference doesn't help. You need both useCallback AND memo() on the child for this pattern to work.
Q: Can I use useCallback with async functions?
A: Not directly. useCallback can't wrap an async function as its first argument. Instead:
// ❌ WRONG
const fetch = useCallback(async () => { /* ... */ }, []);
// ✅ CORRECT
const fetch = useCallback(() => {
return (async () => { /* ... */ })();
}, []);
// Or use useEffect for async operations
Q: What if I have many dependencies in useCallback?
A: If the dependency array is large and frequently changes, useCallback provides little benefit. This is fine—it means the callback should recreate frequently. You're using the right tool in the right situation.
Q: Should I use useCallback in custom Hooks?
A: Often yes. If your custom Hook returns a callback that will be used as a dependency elsewhere:
function useFetch(url: string) {
// ✅ Memoize the fetch function
const fetch = useCallback(async () => {
const response = await fetch(url);
return response.json();
}, [url]);
return fetch; // Stable across re-renders (unless url changes)
}
Related Articles
- useState Hook: Managing Component State
- useMemo: Memoizing Expensive Values
- React.memo: Preventing Unnecessary Re-renders
- useEffect Hook: 6 Common Mistakes
Questions? Have you run into situations where useCallback helped or hurt your performance? Share your experiences in the comments. What optimization patterns have you found most useful in large-scale applications?
Google AdSense Placeholder
CONTENT SLOT