Skip to main content

`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:


🎯 Article Outline: What You'll Master

In this article, you will learn:

  • Event Handlers in Lists: Strategies for using useCallback effectively when rendering lists of items with individual actions, including the common "pass an ID" pattern.
  • useCallback in Custom Hooks: How and why to use useCallback for 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 useCallback and 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:

  1. ProductItem: A memoized child. Its onClick for the button now calls props.onAddToCart(product.id), passing its own product.id to the handler it received.
  2. ProductCatalog:
    • handleAddToCart = useCallback((productId) => { ... }, [products]);
      • A single function is defined and memoized.
      • It takes productId as an argument.
      • It depends on products because it needs to find the product. If products changes, this function needs a new version to close over the new products array.
    • When mapping, onAddToCart={handleAddToCart} passes the same stable function reference to every ProductItem.
  3. Optimization: When ProductCatalog re-renders due to filterText changing:
    • handleAddToCart reference remains stable (assuming products hasn't changed).
    • Each ProductItem receives the same product prop (if that item isn't filtered out) and the same onAddToCart function reference.
    • Thus, ProductItem components 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:

  1. useToggle Custom Hook:
    • Manages an isOn state.
    • Returns the isOn state and a toggle function.
    • toggle = useCallback(() => { setIsOn(prevIsOn => !prevIsOn); }, []);
      • The toggle function is memoized. Since setIsOn from useState is guaranteed to have a stable reference and the toggle logic doesn't depend on other component scope variables, the dependency array is empty.
  2. App Component:
    • Uses useToggle twice for featureA and featureB.
    • toggleFeatureA and toggleFeatureB are the memoized functions returned by useToggle.
  3. ToggleButton: A React.memo-wrapped component that receives the onToggle callback.
  4. Optimization: When App re-renders due to unrelatedCounter changing:
    • toggleFeatureA and toggleFeatureB retain their stable references from useToggle.
    • ToggleButton components receive the same onToggle function references.
    • Thus, they correctly skip re-rendering (assuming their status prop 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:

  1. logCountStale: Defined with useCallback(..., []). Because count and messagePrefix are not in its dependency array, it "closes over" the initial values of count (0) and messagePrefix ("Count is:") from the first render. No matter how many times you increment count or change messagePrefix, clicking "Log Stale Count" will always log the initial values after the delay.
  2. logCountCorrect: Defined with useCallback(..., [count, messagePrefix]). This version correctly lists count and messagePrefix as dependencies. When either count or messagePrefix changes, useCallback returns 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 the logCountCorrect function 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 from React.memo, using useCallback might be a slight net negative in performance or just add unnecessary code complexity.
  • Is React.memo even needed?: Before optimizing callbacks with useCallback, ask if the child component truly needs React.memo. If the child is simple and renders quickly, or if its props change frequently anyway, React.memo (and by extension, useCallback for 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 useReducer for 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 useCallback to 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 another useCallback, it must have a stable reference, making useCallback essential.

✨ Section 5: Real-World Scenarios and Nuances

Let's consider a few more scenarios:

  1. 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} />;
    }
  2. Inline Functions vs. useCallback:

    • Inline in JSX (onClick={() => doSomething(item.id)}): This always creates a new function. If passed to a React.memo child, it will cause re-renders. This is the exact scenario useCallback (often with the "pass an ID" pattern) aims to solve.
    • Inline in useEffect dependencies: If you define a function directly in useEffect's setup, it's fine. If you define it outside but want to call it from useEffect and it needs to be stable, useCallback is the way.
  3. Relying on setXxx from useState: The setXxx function returned by useState is guaranteed by React to have a stable reference and does not need to be included in dependency arrays for useCallback or useEffect.

    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 useCallback to 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 useCallback or useMemo returning 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