`useCallback` for Memoizing Functions (Part 2): Practical Examples & Nuances #127
📖 Introduction
Welcome to Part 2 of our exploration of useCallback! In Part 1, we established why useCallback is essential for memoizing functions, primarily to provide stable references for props passed to React.memo-wrapped children and for dependencies in other hooks like useEffect.
In this article, we'll delve into more practical examples and nuanced scenarios. We'll look at using useCallback with event handlers in lists, its role within custom hooks, and further refine our understanding of when and when not to reach for this optimization tool. Mastering these practical applications will help you write cleaner, more performant React code.
📚 Prerequisites
Ensure you have a strong understanding of:
- All concepts from
useCallbackfor Memoizing Functions (Part 1). React.memoand how it uses prop comparison.- JavaScript closures and the rules of hooks (especially dependency arrays).
- Basic custom hook structure.
🎯 Article Outline: What You'll Master
In this article, you will learn:
- ✅ Event Handlers in Lists: Strategies for using
useCallbackeffectively when rendering lists of items with individual actions, including the common "pass an ID" pattern. - ✅
useCallbackin Custom Hooks: How and why to useuseCallbackfor functions returned by custom hooks. - ✅ Advanced Dependency Management: Dealing with functions as dependencies and avoiding stale closures in more complex callbacks.
- ✅ Performance Considerations Revisited: The trade-offs of using
useCallbackand when alternative component structures might be better. - ✅ Real-world Scenarios: Practical examples to solidify your understanding.
🧠 Section 1: Event Handlers in Lists – Common Patterns & Challenges
One of the most frequent places developers consider useCallback is when rendering a list of items, where each item has its own interactive elements (e.g., buttons to delete, edit, or select an item).
1.1 The Challenge: New Functions on Each Item Render
If you define an event handler directly within a loop (e.g., in a .map() call), you'll create a new function reference for each item on every render of the list's parent component. This negates the benefit of React.memo on the list items.
Problematic Example (Simplified):
const ListItem = React.memo(({ item, onSelect }) => {
console.log(`ListItem rendered: ${item.name}`);
return <div onClick={onSelect}>Select {item.name}</div>;
});
function MyList({ items }) {
const [selectedItem, setSelectedItem] = useState(null);
// ... other state causing MyList to re-render ...
return (
<div>
{items.map(item => (
// 🚩 onSelect is a NEW function for each item, on every MyList render
<ListItem
key={item.id}
item={item}
onSelect={() => setSelectedItem(item.id)}
/>
))}
</div>
);
}
Here, even if items prop doesn't change, if MyList re-renders for another reason, each ListItem gets a new onSelect function reference, causing all of them to re-render.
1.2 Solution 1: Extracting a Child Component (Often Best)
As highlighted in the React documentation (often for useMemo in loops, but the principle is identical for useCallback), the cleanest solution is often to extract the list item into its own component. This new component can then use useCallback correctly at its top level if needed, or it might not even need useCallback if the handler it receives from the parent is already stable.
This approach doesn't directly use useCallback in the loop itself for the handler, but rather enables proper hook usage within the item component.
1.3 Solution 2: A Single Memoized Handler with an Identifier
A very common and scalable pattern is to define a single event handler function in the parent component, memoize it with useCallback, and have it accept an identifier (like itemId) to know which item the action pertains to.
// code-block-1.jsx
// Using a single memoized handler with an item identifier.
import React, { useState, memo, useCallback } from 'react';
const ProductItem = memo(function ProductItem({ product, onAddToCart }) {
console.log(`ProductItem rendered: ${product.name}`);
return (
<div style={{ border: '1px solid #ccc', margin: '5px', padding: '10px' }}>
<h4>{product.name} - ${product.price}</h4>
{/* Child calls the passed handler with its specific ID */}
<button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
</div>
);
});
const initialProducts = [
{ id: 'p1', name: 'Super Keyboard', price: 99 },
{ id: 'p2', name: 'Mega Mouse', price: 49 },
{ id: 'p3', name: 'Ultra Monitor', price: 299 },
];
function ProductCatalog() {
const [products, setProducts] = useState(initialProducts);
const [cart, setCart] = useState([]);
const [filterText, setFilterText] = useState(''); // Unrelated state
// Single memoized handler that takes productId
const handleAddToCart = useCallback((productId) => {
setCart(currentCart => {
const productToAdd = products.find(p => p.id === productId);
if (productToAdd && !currentCart.find(item => item.id === productId)) {
return [...currentCart, productToAdd];
}
return currentCart;
});
console.log(`Added product ${productId} to cart.`);
}, [products]); // Depends on `products` to find the product to add.
// `setCart` is stable.
return (
<div>
<h2>Product Catalog</h2>
<input
type="text"
placeholder="Filter products (causes parent re-render)"
value={filterText}
onChange={e => setFilterText(e.target.value)}
/>
<div style={{ marginTop: '10px' }}>
{products
.filter(p => p.name.toLowerCase().includes(filterText.toLowerCase()))
.map(product => (
<ProductItem
key={product.id}
product={product}
onAddToCart={handleAddToCart} // Pass the single memoized handler
/>
))}
</div>
<h3>Cart:</h3>
<ul>
{cart.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
<p><em>Open console. Type in filter. ProductItems (whose data hasn't changed) should not re-render
because `handleAddToCart` reference is stable. Only when `products` array itself changes
would `handleAddToCart` get a new reference.</em></p>
</div>
);
}
export default ProductCatalog;
Explanation:
ProductItem: A memoized child. ItsonClickfor the button now callsprops.onAddToCart(product.id), passing its ownproduct.idto the handler it received.ProductCatalog:handleAddToCart = useCallback((productId) => { ... }, [products]);- A single function is defined and memoized.
- It takes
productIdas an argument. - It depends on
productsbecause it needs tofindthe product. Ifproductschanges, this function needs a new version to close over the newproductsarray.
- When mapping,
onAddToCart={handleAddToCart}passes the same stable function reference to everyProductItem.
- Optimization: When
ProductCatalogre-renders due tofilterTextchanging:handleAddToCartreference remains stable (assumingproductshasn't changed).- Each
ProductItemreceives the sameproductprop (if that item isn't filtered out) and the sameonAddToCartfunction reference. - Thus,
ProductItemcomponents whose data hasn't changed will correctly skip re-rendering.
This pattern is generally preferred for lists as it's more scalable than creating a unique useCallback for every single item in a large list.
💻 Section 2: useCallback in Custom Hooks
Custom hooks are a powerful way to share reusable logic. If your custom hook returns a function (e.g., an event handler, a dispatch-like function), it's often a good practice to memoize that function with useCallback. This makes your custom hook easier to use optimally by consumers, as they receive stable function references.
Example: A useToggle Custom Hook
// code-block-2.jsx
// Using useCallback for a function returned by a custom hook.
import React, { useState, useCallback } from 'react';
// Custom hook useToggle
function useToggle(initialValue = false) {
const [isOn, setIsOn] = useState(initialValue);
// Memoize the toggle function
const toggle = useCallback(() => {
setIsOn(prevIsOn => !prevIsOn);
}, []); // setIsOn is stable, so empty dependency array is fine.
return [isOn, toggle];
}
// Component using the custom hook
const ToggleButton = React.memo(({ id, status, onToggle }) => {
console.log(`ToggleButton ${id} rendered`);
return (
<button onClick={onToggle}>
{id}: {status ? 'ON' : 'OFF'}
</button>
);
});
function App() {
const [featureAEnabled, toggleFeatureA] = useToggle(false);
const [featureBEnabled, toggleFeatureB] = useToggle(true);
const [unrelatedCounter, setUnrelatedCounter] = useState(0);
return (
<div>
<h2>Custom Hook with useCallback</h2>
<ToggleButton id="FeatureA" status={featureAEnabled} onToggle={toggleFeatureA} />
<ToggleButton id="FeatureB" status={featureBEnabled} onToggle={toggleFeatureB} />
<hr />
<button onClick={() => setUnrelatedCounter(c => c + 1)}>
Force App Re-render (Counter: {unrelatedCounter})
</button>
<p><em>Open console. When "Force App Re-render" is clicked, ToggleButtons should not re-render
because their `onToggle` prop (from `useToggle`) is stable.</em></p>
</div>
);
}
export default App;
Explanation:
useToggleCustom Hook:- Manages an
isOnstate. - Returns the
isOnstate and atogglefunction. toggle = useCallback(() => { setIsOn(prevIsOn => !prevIsOn); }, []);- The
togglefunction is memoized. SincesetIsOnfromuseStateis guaranteed to have a stable reference and the toggle logic doesn't depend on other component scope variables, the dependency array is empty.
- The
- Manages an
AppComponent:- Uses
useToggletwice forfeatureAandfeatureB. toggleFeatureAandtoggleFeatureBare the memoized functions returned byuseToggle.
- Uses
ToggleButton: AReact.memo-wrapped component that receives theonTogglecallback.- Optimization: When
Appre-renders due tounrelatedCounterchanging:toggleFeatureAandtoggleFeatureBretain their stable references fromuseToggle.ToggleButtoncomponents receive the sameonTogglefunction references.- Thus, they correctly skip re-rendering (assuming their
statusprop also hasn't changed).
By using useCallback inside useToggle, the custom hook provides a more optimized interface by default. Consumers don't have to worry about the returned function causing unnecessary re-renders in their memoized child components.
🛠️ Section 3: Advanced Dependency Management & Stale Closures
The dependency array of useCallback is crucial. If your callback function uses any props, state, or other values from the component scope, those values must be listed in the dependency array. Failing to do so can lead to stale closures, where the callback uses outdated values from the initial render it was created in.
Example of Stale Closure and Fix:
// code-block-3.jsx
// Demonstrating stale closure and fixing it with dependencies.
import React, { useState, useCallback, useEffect } from 'react';
function DelayedLogger() {
const [count, setCount] = useState(0);
const [messagePrefix, setMessagePrefix] = useState("Count is:");
// Problematic: logCountStale will always log the initial count (0) and initial prefix
const logCountStale = useCallback(() => {
setTimeout(() => {
console.log(`STALE LOG: ${messagePrefix} ${count}`); // Closes over initial count & messagePrefix
}, 1000);
}, []); // Empty dependencies!
// Correct: logCountCorrect re-creates if count or messagePrefix changes
const logCountCorrect = useCallback(() => {
setTimeout(() => {
console.log(`CORRECT LOG: ${messagePrefix} ${count}`);
}, 1000);
}, [count, messagePrefix]); // Correct dependencies
useEffect(() => {
// Example of using the callback, perhaps for some external listener
// In a real app, you might pass this to a child or an event listener.
const buttonStale = document.getElementById('btnStale');
const buttonCorrect = document.getElementById('btnCorrect');
if (buttonStale) buttonStale.addEventListener('click', logCountStale);
if (buttonCorrect) buttonCorrect.addEventListener('click', logCountCorrect);
return () => {
if (buttonStale) buttonStale.removeEventListener('click', logCountStale);
if (buttonCorrect) buttonCorrect.removeEventListener('click', logCountCorrect);
};
// This effect itself depends on logCountStale and logCountCorrect.
// If they change, the effect re-runs to attach the new versions.
}, [logCountStale, logCountCorrect]);
return (
<div>
<h2>Stale Closure Demo</h2>
<p>Current Count: {count}</p>
<p>Message Prefix: <input type="text" value={messagePrefix} onChange={e => setMessagePrefix(e.target.value)} /></p>
<button onClick={() => setCount(c => c + 1)}>Increment Count</button>
<hr />
<button id="btnStale">Log Stale Count (after 1s)</button>
<button id="btnCorrect" style={{marginLeft: '10px'}}>Log Correct Count (after 1s)</button>
<p><em>Increment count multiple times, then click both log buttons. Notice the difference.
Change prefix and try again.</em></p>
</div>
);
}
export default DelayedLogger;
Explanation:
logCountStale: Defined withuseCallback(..., []). BecausecountandmessagePrefixare not in its dependency array, it "closes over" the initial values ofcount(0) andmessagePrefix("Count is:") from the first render. No matter how many times you incrementcountor changemessagePrefix, clicking "Log Stale Count" will always log the initial values after the delay.logCountCorrect: Defined withuseCallback(..., [count, messagePrefix]). This version correctly listscountandmessagePrefixas dependencies. When eithercountormessagePrefixchanges,useCallbackreturns a new memoized function that closes over the current values. Clicking "Log Correct Count" will log the values as they were when the button was clicked (or more precisely, when thelogCountCorrectfunction it invoked was last memoized).
Always ensure your dependency arrays are exhaustive. The eslint-plugin-react-hooks package has a lint rule (react-hooks/exhaustive-deps) that is extremely helpful in catching missing dependencies.
🔬 Section 4: Performance Considerations Revisited & Alternatives
While useCallback is a valuable optimization tool, it's not a silver bullet.
- Cost of
useCallback: The hook itself has a small overhead: React needs to store the memoized function and compare the dependency array on each render. For very simple functions or components that don't benefit fromReact.memo, usinguseCallbackmight be a slight net negative in performance or just add unnecessary code complexity. - Is
React.memoeven needed?: Before optimizing callbacks withuseCallback, ask if the child component truly needsReact.memo. If the child is simple and renders quickly, or if its props change frequently anyway,React.memo(and by extension,useCallbackfor its props) might not provide significant gains. - Alternative Component Structures:
- Pushing State Down: Sometimes, instead of passing many callbacks down, you can restructure components so that state and the logic that modifies it are closer to where they are used, reducing prop drilling and the need for extensive memoization.
- Compound Components: For complex interactions, the compound component pattern can sometimes offer a cleaner API than prop drilling callbacks.
- Context API: For deeply nested callbacks related to global state, React Context (possibly with
useReducerfor complex state logic) can be an alternative, though memoization concerns can still apply to context values.
When to be more liberal with useCallback:
- Library/Reusable Component Authors: If you're writing a shared component or a custom hook that will be used in many places, it's often good practice to memoize returned functions with
useCallbackto provide a more performant API out-of-the-box for consumers. - As a Dependency for Other Hooks: If a function is used in the dependency array of
useEffect,useMemo, or anotheruseCallback, it must have a stable reference, makinguseCallbackessential.
✨ Section 5: Real-World Scenarios and Nuances
Let's consider a few more scenarios:
-
Functions that Don't Change: If a function truly never changes and doesn't depend on any component scope variables (e.g., a utility function defined outside the component or a static method), it doesn't need
useCallback. It already has a stable reference.function utilityHelper(data) { /* ... */ }
function MyComponent({ data }) {
// No need for useCallback(utilityHelper, []) if utilityHelper is defined outside
return <MemoizedChild processData={utilityHelper} data={data} />;
} -
Inline Functions vs.
useCallback:- Inline in JSX (
onClick={() => doSomething(item.id)}): This always creates a new function. If passed to aReact.memochild, it will cause re-renders. This is the exact scenariouseCallback(often with the "pass an ID" pattern) aims to solve. - Inline in
useEffectdependencies: If you define a function directly inuseEffect's setup, it's fine. If you define it outside but want to call it fromuseEffectand it needs to be stable,useCallbackis the way.
- Inline in JSX (
-
Relying on
setXxxfromuseState: ThesetXxxfunction returned byuseStateis guaranteed by React to have a stable reference and does not need to be included in dependency arrays foruseCallbackoruseEffect.const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount(c => c + 1); // or setCount(count + 1) if `count` is a dependency
}, []); // `setCount` is stable, so empty array is fine if not using `count` directly.
// If using `count` like `setCount(count + 1)`, then `[count]` is needed.
💡 Conclusion & Key Takeaways
useCallback is a specialized hook for memoizing functions, primarily to ensure referential stability when functions are passed as props to memoized children or used as dependencies in other hooks. While powerful, it should be used judiciously.
Key Takeaways for Part 2:
- Event Handlers in Lists: Use the "pass an ID to a single memoized handler" pattern for scalability.
- Custom Hooks: Memoize functions returned by custom hooks with
useCallbackto provide a stable API. - Dependency Arrays are Crucial: Always correctly specify dependencies to avoid stale closures and ensure the callback updates when its underlying data changes.
- Consider Alternatives: Sometimes component restructuring or different state management patterns can obviate the need for
useCallback. - Not a Universal Solution: Don't wrap every function. Profile and apply where it demonstrably improves performance or is necessary for correctness (e.g., effect dependencies).
Challenge Yourself:
Refactor the ProductCatalog example from earlier (or a similar list component you've built). Instead of passing the handleAddToCart function directly, modify ProductItem to accept productId as a separate prop and the generic handleAddToCart function. Inside ProductItem, create the final onClick handler (e.g., const handleClick = () => onAddToCart(productId);). Now, think: does this handleClick inside ProductItem need useCallback? Why or why not? Consider what happens if ProductItem itself has internal state that causes it to re-render.
➡️ Next Steps
You now have a much deeper understanding of useCallback and its practical applications. The final piece in our core optimization hook puzzle is understanding how to find performance bottlenecks in the first place.
In our next and final article in this series, "Profiling Your App with the React DevTools Profiler: Finding performance bottlenecks.", we'll learn how to use the React DevTools Profiler to identify which components are rendering too often or taking too long, guiding your optimization efforts with React.memo, useMemo, and useCallback.
Happy (performant) coding!
glossary
- Stale Closure (in React hooks): Occurs when a memoized callback (from
useCallbackoruseMemoreturning a function) with an incomplete dependency array closes over outdated values of state or props from the render it was first created in. - Custom Hook: A JavaScript function whose name starts with "use" and that can call other Hooks. It's a mechanism to reuse stateful logic between components.
- Identifier (in event handling): A piece of data (e.g., an item's ID) passed to a generic event handler to specify which item the event pertains to.
Further Reading
- React Docs:
useCallback - When to useMemo and useCallback - Kent C. Dodds
- React Docs: My function runs twice on every re-render (Strict Mode) (Relevant for observing callback creation/behavior)