Welcome to useCallback!
Imagine you're a director giving the same instructions to actors repeatedly. Instead of explaining the entire scene each time, you could just say "do it like we rehearsed." useCallback is like creating a rehearsed version of a function - it remembers the function definition so child components don't think they're getting new instructions every time!
graph TD
A[Parent Component Renders] --> B{Create Function}
B --> C[Without useCallback: New Function Every Time]
B --> D[With useCallback: Same Function Reference]
C --> E[Child Component Sees New Prop]
D --> F[Child Component Sees Same Prop]
E --> G[Child Re-renders]
F --> H[Child Skips Re-render]
style C fill:#ff9999
style D fill:#99ff99
style H fill:#99ff99
Understanding useCallback
1. The Problem: Function Identity
// The Problem: Functions are recreated on every render
function ParentComponent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// This function is recreated on every render!
const handleClick = () => {
console.log('Button clicked!');
};
return (
<div>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type here..."
/>
<div>Count: {count}</div>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
{/* ExpensiveChild re-renders whenever ParentComponent re-renders
because handleClick is a new function each time */}
<ExpensiveChild onClick={handleClick} />
</div>
);
}
// Even with React.memo, this component re-renders unnecessarily
const ExpensiveChild = React.memo(function ExpensiveChild({ onClick }) {
console.log('ExpensiveChild rendered');
// Simulate expensive operation
const startTime = performance.now();
while (performance.now() - startTime < 100) {
// Artificial delay
}
return (
<div className="expensive-child">
<button onClick={onClick}>
Click me (Expensive to render)
</button>
</div>
);
});
// The Solution: useCallback
function OptimizedParentComponent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// This function is memoized and only recreated when dependencies change
const handleClick = useCallback(() => {
console.log('Button clicked!');
}, []); // Empty dependency array = function never changes
return (
<div>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type here..."
/>
<div>Count: {count}</div>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
{/* ExpensiveChild only re-renders when handleClick changes
(which is never in this case) */}
<ExpensiveChild onClick={handleClick} />
</div>
);
}
2. useCallback with Dependencies
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [selectedId, setSelectedId] = useState(null);
// Bad: Function recreated on every render (including when selectedId changes)
const badHandleSearch = () => {
fetchResults(query).then(setResults);
};
// Better: Only recreated when query changes
const handleSearch = useCallback(() => {
fetchResults(query).then(setResults);
}, [query]);
// Function that depends on state
const handleSelect = useCallback((id) => {
setSelectedId(id);
// Imagine we need to do something with the current query
console.log(`Selected ${id} while searching for "${query}"`);
}, [query]); // Recreated when query changes
// Function that doesn't depend on external values
const handleClear = useCallback(() => {
setQuery('');
setResults([]);
setSelectedId(null);
}, []); // Never recreated
return (
<div>
<SearchInput
value={query}
onChange={setQuery}
onSearch={handleSearch}
onClear={handleClear}
/>
<SearchResults
results={results}
selectedId={selectedId}
onSelect={handleSelect}
/>
</div>
);
}
3. Common useCallback Patterns
// Pattern 1: Event Handlers
function FormComponent() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
// Memoized change handler
const handleChange = useCallback((field) => (event) => {
setFormData(prev => ({
...prev,
[field]: event.target.value
}));
}, []);
// Memoized submit handler
const handleSubmit = useCallback(async (event) => {
event.preventDefault();
try {
await submitForm(formData);
// Reset form or show success message
} catch (error) {
// Handle error
}
}, [formData]);
return (
<form onSubmit={handleSubmit}>
<FormField
label="Name"
value={formData.name}
onChange={handleChange('name')}
/>
<FormField
label="Email"
value={formData.email}
onChange={handleChange('email')}
/>
<FormField
label="Message"
value={formData.message}
onChange={handleChange('message')}
multiline
/>
<button type="submit">Send</button>
</form>
);
}
// Pattern 2: API Calls
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Memoized fetch function
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await api.getUsers();
setUsers(response.data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []); // No dependencies, API call doesn't depend on component state
// Memoized delete handler
const handleDelete = useCallback(async (userId) => {
try {
await api.deleteUser(userId);
// Remove user from local state
setUsers(prevUsers => prevUsers.filter(user => user.id !== userId));
} catch (err) {
setError(err.message);
}
}, []); // No dependencies needed
// Fetch users on mount
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
return (
<div>
{loading && <Spinner />}
{error && <ErrorMessage message={error} />}
<UserListDisplay
users={users}
onDelete={handleDelete}
onRefresh={fetchUsers}
/>
</div>
);
}
// Pattern 3: Debounced Functions
function SearchableList({ items }) {
const [searchTerm, setSearchTerm] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
// Create debounced search function
const debouncedSearch = useCallback(
debounce((term) => {
const filtered = items.filter(item =>
item.name.toLowerCase().includes(term.toLowerCase())
);
setFilteredItems(filtered);
}, 300),
[items] // Recreate when items change
);
// Handle search input change
const handleSearchChange = useCallback((event) => {
const value = event.target.value;
setSearchTerm(value);
debouncedSearch(value);
}, [debouncedSearch]);
return (
<div>
<SearchInput
value={searchTerm}
onChange={handleSearchChange}
placeholder="Search items..."
/>
<ItemList items={filteredItems} />
</div>
);
}
Advanced useCallback Patterns
1. Callback Composition
function AdvancedForm() {
const [formData, setFormData] = useState({});
const [validation, setValidation] = useState({});
const [submitting, setSubmitting] = useState(false);
// Base update function
const updateField = useCallback((field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
}, []);
// Validation function
const validateField = useCallback((field, value) => {
const errors = {};
switch (field) {
case 'email':
if (!value.includes('@')) {
errors.email = 'Invalid email address';
}
break;
case 'password':
if (value.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
break;
// ... other validations
}
setValidation(prev => ({
...prev,
...errors
}));
return Object.keys(errors).length === 0;
}, []);
// Composed update with validation
const updateFieldWithValidation = useCallback((field) => (event) => {
const value = event.target.value;
updateField(field, value);
validateField(field, value);
}, [updateField, validateField]);
// Submit handler with validation
const handleSubmit = useCallback(async (event) => {
event.preventDefault();
// Validate all fields
const isValid = Object.keys(formData).every(field =>
validateField(field, formData[field])
);
if (!isValid) return;
setSubmitting(true);
try {
await api.submitForm(formData);
// Handle success
} catch (error) {
// Handle error
} finally {
setSubmitting(false);
}
}, [formData, validateField]);
return (
<form onSubmit={handleSubmit}>
<FormField
name="email"
value={formData.email || ''}
onChange={updateFieldWithValidation('email')}
error={validation.email}
/>
<FormField
name="password"
type="password"
value={formData.password || ''}
onChange={updateFieldWithValidation('password')}
error={validation.password}
/>
<button type="submit" disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
2. Dynamic Callbacks
function DynamicList({ items, renderItem }) {
const [selectedIds, setSelectedIds] = useState(new Set());
const [sortConfig, setSortConfig] = useState({ key: 'name', direction: 'asc' });
// Create item-specific callbacks dynamically
const createItemCallbacks = useCallback((item) => ({
onSelect: () => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(item.id)) {
next.delete(item.id);
} else {
next.add(item.id);
}
return next;
});
},
onEdit: () => {
// Navigate to edit page or open modal
console.log('Editing item:', item.id);
},
onDelete: async () => {
if (window.confirm('Are you sure?')) {
try {
await api.deleteItem(item.id);
// Update local state
} catch (error) {
console.error('Delete failed:', error);
}
}
}
}), []);
// Memoize the callback creator itself
const getItemProps = useCallback((item) => {
const callbacks = createItemCallbacks(item);
return {
item,
isSelected: selectedIds.has(item.id),
...callbacks
};
}, [selectedIds, createItemCallbacks]);
// Sort items
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => {
const aValue = a[sortConfig.key];
const bValue = b[sortConfig.key];
const direction = sortConfig.direction === 'asc' ? 1 : -1;
if (aValue < bValue) return -1 * direction;
if (aValue > bValue) return 1 * direction;
return 0;
});
}, [items, sortConfig]);
return (
<div>
<SortControls
config={sortConfig}
onChange={setSortConfig}
/>
<div className="item-list">
{sortedItems.map(item => (
<MemoizedItem
key={item.id}
{...getItemProps(item)}
render={renderItem}
/>
))}
</div>
</div>
);
}
// Memoized item component
const MemoizedItem = React.memo(function Item({ item, isSelected, onSelect, onEdit, onDelete, render }) {
return render({ item, isSelected, onSelect, onEdit, onDelete });
});
3. Callback with Refs
function VideoPlayer({ src }) {
const videoRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
// Callbacks that interact with refs
const play = useCallback(() => {
if (videoRef.current) {
videoRef.current.play();
setIsPlaying(true);
}
}, []);
const pause = useCallback(() => {
if (videoRef.current) {
videoRef.current.pause();
setIsPlaying(false);
}
}, []);
const seek = useCallback((time) => {
if (videoRef.current) {
videoRef.current.currentTime = time;
setCurrentTime(time);
}
}, []);
const togglePlay = useCallback(() => {
if (isPlaying) {
pause();
} else {
play();
}
}, [isPlaying, play, pause]);
// Event handlers
const handleTimeUpdate = useCallback(() => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
}, []);
const handleLoadedMetadata = useCallback(() => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
}
}, []);
return (
<div className="video-player">
<video
ref={videoRef}
src={src}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
/>
<VideoControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
onPlayPause={togglePlay}
onSeek={seek}
/>
</div>
);
}
Performance Patterns with useCallback
1. Optimizing List Rendering
function OptimizedList({ items }) {
const [selectedId, setSelectedId] = useState(null);
// Memoize the selection handler
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []);
return (
<div>
{items.map(item => (
<OptimizedListItem
key={item.id}
item={item}
isSelected={item.id === selectedId}
onSelect={handleSelect}
/>
))}
</div>
);
}
// Optimized list item with React.memo
const OptimizedListItem = React.memo(function ListItem({
item,
isSelected,
onSelect
}) {
console.log(`Rendering item ${item.id}`);
// Create a stable callback for this specific item
const handleClick = useCallback(() => {
onSelect(item.id);
}, [item.id, onSelect]);
return (
<div
className={`list-item ${isSelected ? 'selected' : ''}`}
onClick={handleClick}
>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
);
});
2. Optimizing Context Providers
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
// Memoize all context methods
const login = useCallback(async (credentials) => {
try {
const userData = await api.login(credentials);
setUser(userData);
return userData;
} catch (error) {
throw error;
}
}, []);
const logout = useCallback(() => {
setUser(null);
// Clear other auth-related state
}, []);
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, []);
const addNotification = useCallback((notification) => {
const id = Date.now();
setNotifications(prev => [...prev, { ...notification, id }]);
// Auto-dismiss after 5 seconds
setTimeout(() => {
removeNotification(id);
}, 5000);
}, []);
const removeNotification = useCallback((id) => {
setNotifications(prev => prev.filter(n => n.id !== id));
}, []);
// Memoize the context value
const contextValue = useMemo(() => ({
user,
theme,
notifications,
login,
logout,
toggleTheme,
addNotification,
removeNotification
}), [
user,
theme,
notifications,
login,
logout,
toggleTheme,
addNotification,
removeNotification
]);
return (
<AppContext.Provider value={contextValue}>
{children}
</AppContext.Provider>
);
}
3. Optimizing Event Delegation
function DataTable({ rows, columns }) {
const [sortConfig, setSortConfig] = useState(null);
const [selectedRows, setSelectedRows] = useState(new Set());
// Single event handler for all cell clicks
const handleCellClick = useCallback((event) => {
const cell = event.target.closest('td');
if (!cell) return;
const rowId = cell.parentElement.dataset.rowId;
const columnId = cell.dataset.columnId;
// Handle different types of clicks
if (cell.classList.contains('selectable')) {
setSelectedRows(prev => {
const next = new Set(prev);
if (next.has(rowId)) {
next.delete(rowId);
} else {
next.add(rowId);
}
return next;
});
} else if (cell.classList.contains('sortable')) {
setSortConfig(prev => ({
column: columnId,
direction: prev?.column === columnId && prev.direction === 'asc'
? 'desc'
: 'asc'
}));
}
}, []);
// Memoize sorted rows
const sortedRows = useMemo(() => {
if (!sortConfig) return rows;
return [...rows].sort((a, b) => {
const aValue = a[sortConfig.column];
const bValue = b[sortConfig.column];
const direction = sortConfig.direction === 'asc' ? 1 : -1;
if (aValue < bValue) return -1 * direction;
if (aValue > bValue) return 1 * direction;
return 0;
});
}, [rows, sortConfig]);
return (
<table onClick={handleCellClick}>
<thead>
<tr>
<th className="selectable">
<input
type="checkbox"
checked={selectedRows.size === rows.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedRows(new Set(rows.map(r => r.id)));
} else {
setSelectedRows(new Set());
}
}}
/>
</th>
{columns.map(column => (
<th
key={column.id}
data-column-id={column.id}
className={column.sortable ? 'sortable' : ''}
>
{column.label}
{sortConfig?.column === column.id && (
<span>{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedRows.map(row => (
<tr key={row.id} data-row-id={row.id}>
<td className="selectable">
<input
type="checkbox"
checked={selectedRows.has(row.id)}
readOnly
/>
</td>
{columns.map(column => (
<td key={column.id} data-column-id={column.id}>
{row[column.id]}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Common Pitfalls and Solutions
1. Missing Dependencies
// PROBLEM: Missing dependency causes stale closure
function ProblemComponent({ userId }) {
const [userData, setUserData] = useState(null);
// Missing userId in dependencies!
const fetchUser = useCallback(async () => {
const data = await api.getUser(userId);
setUserData(data);
}, []); // Should include userId
useEffect(() => {
fetchUser();
}, [fetchUser]);
// If userId changes, fetchUser still uses the old value!
// SOLUTION: Include all dependencies
const fetchUserFixed = useCallback(async () => {
const data = await api.getUser(userId);
setUserData(data);
}, [userId]); // Include userId in dependencies
}
// Better solution: Pass parameters directly
function BetterComponent({ userId }) {
const [userData, setUserData] = useState(null);
const fetchUser = useCallback(async (id) => {
const data = await api.getUser(id);
setUserData(data);
}, []); // No dependencies needed
useEffect(() => {
fetchUser(userId);
}, [fetchUser, userId]);
}
2. Over-using useCallback
// PROBLEM: Unnecessary useCallback
function OverOptimized() {
const [count, setCount] = useState(0);
// These are unnecessary!
const increment = useCallback(() => {
setCount(c => c + 1);
}, []);
const decrement = useCallback(() => {
setCount(c => c - 1);
}, []);
const reset = useCallback(() => {
setCount(0);
}, []);
// Simple inline functions are fine here
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+</button>
<button onClick={() => setCount(c => c - 1)}>-</button>
<button onClick={() => setCount(0)}>Reset</button>
<div>Count: {count}</div>
</div>
);
}
// SOLUTION: Only use useCallback when necessary
function Optimized() {
const [count, setCount] = useState(0);
const [items, setItems] = useState([]);
// Only memoize if passed to optimized child components
const handleItemClick = useCallback((id) => {
console.log('Item clicked:', id);
}, []);
return (
<div>
<div>
<button onClick={() => setCount(c => c + 1)}>+</button>
<div>Count: {count}</div>
</div>
<ExpensiveList items={items} onItemClick={handleItemClick} />
</div>
);
}
3. Dependencies on Objects/Arrays
// PROBLEM: Object/array dependencies
function ProblemComponent({ config }) {
// This will recreate on every render if config object changes reference
const processData = useCallback((data) => {
return data.filter(item => item.value > config.threshold);
}, [config]); // Entire config object as dependency
// SOLUTION 1: Destructure needed values
const processDataFixed = useCallback((data) => {
return data.filter(item => item.value > config.threshold);
}, [config.threshold]); // Only the specific value needed
// SOLUTION 2: Pass values as parameters
const processDataBetter = useCallback((data, threshold) => {
return data.filter(item => item.value > threshold);
}, []); // No dependencies needed
// Use like this:
const result = processDataBetter(data, config.threshold);
}
Testing Components with useCallback
import { render, fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// Component to test
function SearchForm({ onSearch }) {
const [query, setQuery] = useState('');
const handleSubmit = useCallback((e) => {
e.preventDefault();
onSearch(query);
}, [query, onSearch]);
const handleChange = useCallback((e) => {
setQuery(e.target.value);
}, []);
return (
<form onSubmit={handleSubmit}>
<input
value={query}
onChange={handleChange}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}
// Tests
describe('SearchForm', () => {
it('calls onSearch with query when submitted', async () => {
const handleSearch = jest.fn();
render(<SearchForm onSearch={handleSearch} />);
const input = screen.getByPlaceholderText('Search...');
const button = screen.getByText('Search');
// Type in the input
await userEvent.type(input, 'test query');
// Submit the form
fireEvent.click(button);
expect(handleSearch).toHaveBeenCalledWith('test query');
});
it('maintains callback reference stability', () => {
const handleSearch = jest.fn();
const { rerender } = render(<SearchForm onSearch={handleSearch} />);
// Store initial onChange reference
const input = screen.getByPlaceholderText('Search...');
const initialOnChange = input.props.onChange;
// Rerender with same props
rerender(<SearchForm onSearch={handleSearch} />);
// onChange should be the same reference
expect(input.props.onChange).toBe(initialOnChange);
});
});
// Testing memoization effectiveness
function MemoizedChild({ onClick }) {
const renderCount = useRef(0);
renderCount.current++;
return (
<button onClick={onClick}>
Rendered {renderCount.current} times
</button>
);
}
describe('useCallback memoization', () => {
it('prevents unnecessary re-renders of child components', () => {
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Increment: {count}
</button>
<MemoizedChild onClick={handleClick} />
</div>
);
}
render(<Parent />);
const incrementButton = screen.getByText(/Increment:/);
const memoizedButton = screen.getByText(/Rendered 1 times/);
// Click increment button multiple times
fireEvent.click(incrementButton);
fireEvent.click(incrementButton);
// Child should still show only 1 render
expect(memoizedButton).toHaveTextContent('Rendered 1 times');
});
});
Best Practices
1. Only Memoize When Necessary
// Only use useCallback when:
// 1. Passing to optimized child components
// 2. The function is expensive to create
// 3. Used in dependency arrays of other hooks
// Good use case
const ParentComponent = React.memo(({ children, onAction }) => {
// Memoized because passed to memo component
const handleAction = useCallback(() => {
onAction();
}, [onAction]);
return <ChildComponent onAction={handleAction} />;
});
// Unnecessary use case
function SimpleComponent() {
// Don't memoize simple event handlers
return <button onClick={() => console.log('clicked')}>Click</button>;
}
2. Keep Dependencies Minimal
// Instead of object dependencies
const handleFilter = useCallback((data) => {
return data.filter(item => item.value > config.threshold);
}, [config]); // Entire object as dependency
// Use specific values
const handleFilter = useCallback((data) => {
return data.filter(item => item.value > config.threshold);
}, [config.threshold]); // Only what you need
// Or pass as parameters
const handleFilter = useCallback((data, threshold) => {
return data.filter(item => item.value > threshold);
}, []); // No dependencies
3. Combine with useMemo and React.memo
const MemoizedComponent = React.memo(({ data, onAction }) => {
const processedData = useMemo(() => {
return expensiveOperation(data);
}, [data]);
return (
<div>
{processedData.map(item => (
<div key={item.id} onClick={() => onAction(item.id)}>
{item.name}
</div>
))}
</div>
);
});
function Parent() {
const [data, setData] = useState([]);
const handleAction = useCallback((id) => {
console.log('Action on item:', id);
}, []);
return <MemoizedComponent data={data} onAction={handleAction} />;
}
Practice Exercises
Exercise 1: Optimize a Form
Create a form with multiple fields that:
- Prevents unnecessary re-renders of field components
- Memoizes validation functions
- Optimizes submit handler
- Handles field-specific onChange handlers efficiently
Exercise 2: Build an Infinite Scroll List
Create a list component that:
- Loads more items when scrolling to bottom
- Memoizes item click handlers
- Prevents re-renders of existing items
- Handles selection state efficiently
Exercise 3: Create a Data Grid
Build a data grid component with:
- Sortable columns
- Row selection
- Cell editing
- Optimized event handlers using useCallback
Key Takeaways
- useCallback memoizes function references to prevent unnecessary re-renders
- Only use useCallback when passing functions to optimized child components
- Always include all dependencies in the dependency array
- Combine with React.memo for maximum optimization
- Don't over-optimize - measure performance first
- Consider passing parameters instead of using dependencies
- Be careful with object and array dependencies