Skip to main content

`useCallback` for Memoizing Functions (Part 1): Stable Props & Effects #126

📖 Introduction

In our previous articles on useMemo (Part 1 and Part 2), we explored how to memoize expensive calculations and preserve referential equality for objects and arrays. Now, we turn our attention to a closely related hook: useCallback.

Functions in JavaScript are also reference types. This means if you define a function inside a component, a new function reference is created on every render. If this function is passed as a prop to a child component wrapped with React.memo, or used as a dependency in useEffect, this can lead to unnecessary re-renders or effect executions. useCallback is the specialized hook designed to solve this by memoizing functions.


📚 Prerequisites

Before diving in, make sure you're familiar with:

  • All concepts from our useMemo articles, especially referential equality.
  • React.memo.
  • JavaScript functions as first-class citizens (they can be passed as arguments, returned from other functions, and assigned to variables).
  • Basic understanding of useEffect.

🎯 Article Outline: What You'll Master

In this article, you will learn:

  • The Problem with Function Props: Why new function references can break React.memo optimizations.
  • Introducing useCallback: Its syntax and how it memoizes functions.
  • Core Use Case: Passing stable callback functions to memoized child components.
  • useCallback and useEffect: Providing stable function dependencies for effects.
  • Best Practices (Initial Look): When and why to use useCallback.

🧠 Section 1: The Core Concepts of useCallback

Functions in JavaScript are objects. This means when you define a function, even if its definition is identical across renders, React (and JavaScript) will treat each definition as a new, distinct function in memory, with a new reference.

Consider this scenario:

function Parent() {
const [count, setCount] = useState(0);

// handleClick is a NEW function on every render of Parent
const handleClick = () => {
console.log("Button clicked!");
};

return (
<>
<button onClick={() => setCount(c => c + 1)}>Increment: {count}</button>
<MemoizedChildComponent onButtonClick={handleClick} />
</>
);
}

const MemoizedChildComponent = React.memo(function Child({ onButtonClick }) {
console.log("MemoizedChildComponent rendered");
return <button onClick={onButtonClick}>Click Me (Child)</button>;
});

Even though MemoizedChildComponent is wrapped in React.memo, it will re-render every time Parent re-renders (e.g., when count changes). Why? Because the handleClick function, though its code is the same, is a new function reference on each render of Parent. React.memo sees props.onButtonClick as having changed.

useCallback to the Rescue

useCallback is a hook that returns a memoized version of the callback function that only changes if one of the dependencies has changed. This is useful when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary updates.

Syntax:

const memoizedCallback = useCallback(
() => {
// doSomething(a, b);
},
[a, b], // Dependencies
);
  • First argument: The function you want to memoize.
  • Second argument: A dependency array. The memoized callback will only be re-created if one of these dependencies changes. If the dependency array is empty ([]), the callback is created once and memoized for the component's lifetime.

Relationship to useMemo: useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). useCallback is essentially syntactic sugar for useMemo when the value you want to memoize is a function itself.


💻 Section 2: Deep Dive - Implementation and Walkthrough

Let's fix the previous example using useCallback.

2.1 - Stabilizing a Callback for a Memoized Child

// code-block-1.jsx
// Using useCallback to stabilize a function prop for a React.memo child.

import React, { useState, memo, useCallback } from 'react';

const ClickableButton = memo(function ClickableButton({ onClick, label }) {
// This log will show us when ClickableButton re-renders
console.log(`ClickableButton rendered: ${label}`);
return (
<button onClick={onClick} style={{ margin: '5px' }}>
{label}
</button>
);
});

function App() {
const [count, setCount] = useState(0); // Drives re-renders of App
const [buttonText, setButtonText] = useState("Child Action");

// Memoize the handleChildClick function
const handleChildClick = useCallback(() => {
console.log(`${buttonText} button was clicked!`);
// If this function needs to use `buttonText`, it should be in dependencies.
// For this example, let's assume it doesn't initially need it to show basic memoization.
}, []); // Empty dependency array: function is created once.
// If it used `buttonText` it would be: [buttonText]

// An example of a callback that DOES depend on component state
const handleParentIncrement = useCallback(() => {
setCount(c => c + 1);
console.log("Parent incremented count");
}, []); // setCount is guaranteed to be stable by React.

return (
<div>
<h2>Callback Demo</h2>
<p>Parent Counter: {count}</p>
<button onClick={handleParentIncrement}>Increment Parent Counter</button>
<hr/>
<input
type="text"
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
placeholder="Change child button action text"
/>
<ClickableButton onClick={handleChildClick} label={buttonText} />
<p>
<em>
Open console. When "Increment Parent Counter" is clicked, App re-renders.
"ClickableButton rendered: ..." should NOT log again if its `label` prop (buttonText)
hasn't changed, because `handleChildClick` reference is stable.
If you change the input text, `ClickableButton` will re-render because `label` prop changes.
</em>
</p>
</div>
);
}

export default App;

Step-by-Step Code Breakdown:

  1. ClickableButton: A React.memo-wrapped child component that takes an onClick function prop and a label string prop.
  2. App Component:
    • count state: Used to trigger re-renders of App.
    • buttonText state: Passed as the label prop to ClickableButton.
    • handleChildClick = useCallback(() => { ... }, []);:
      • The event handler function for ClickableButton is wrapped in useCallback.
      • The dependency array is empty ([]). This means handleChildClick will be created only once when App mounts and its reference will remain the same across all subsequent re-renders of App.
    • handleParentIncrement: Also wrapped in useCallback. setCount from useState has a stable reference, so it doesn't strictly need to be a dependency if the function body doesn't change.
  3. Interaction & Optimization:
    • When you click "Increment Parent Counter", the count state in App changes, causing App to re-render.
    • Because handleChildClick is memoized by useCallback with an empty dependency array, its reference does not change.
    • If buttonText (the label prop) also hasn't changed, ClickableButton will receive the same onClick reference and the same label value. Thanks to React.memo, it will correctly skip re-rendering. The "ClickableButton rendered..." log will not appear.
    • If you type in the input field, buttonText changes. This label prop change will cause ClickableButton to re-render, which is expected.

This demonstrates the primary use case of useCallback: providing stable function references to memoized children to prevent them from re-rendering unnecessarily.

Important Note on Dependencies: If handleChildClick needed to access buttonText from its closure, then buttonText must be included in the dependency array:

const handleChildClick = useCallback(() => {
console.log(`${buttonText} button was clicked!`);
}, [buttonText]); // Now depends on buttonText

In this case, handleChildClick would get a new reference whenever buttonText changes. This is correct because the function's behavior changes.


🛠️ Section 3: Project-Based Example: Interactive List with Memoized Item Actions

Let's build a list of items where each item has "promote" and "demote" actions. Each list item component will be memoized, and the action handlers will be passed from the parent. useCallback will be crucial to ensure that only the relevant item re-renders if its specific data changes, not all items when one item's handler is invoked or if the parent re-renders.

The Goal: Create an ItemList component where each Item is memoized. Each Item has action buttons (e.g., "Increment Score"). The functions for these actions are defined in the parent and passed down. We want to use useCallback so that if the parent re-renders, or if one item's action function needs to be updated (e.g., because it depends on that item's specific ID), other items don't re-render due to new action function references.

The Plan:

  1. Create an Item component, wrapped in React.memo, that displays item data and action buttons. It accepts onIncrement as a prop.
  2. Create an ItemList parent component that manages an array of items in its state.
  3. Define an handleIncrementScore function in ItemList.
  4. Initially, pass handleIncrementScore directly. Observe that all Item components might re-render when any item is updated or when ItemList re-renders for other reasons.
  5. Refactor handleIncrementScore using useCallback, ensuring it's correctly scoped or receives necessary item identifiers.
// project-example.jsx
// Using useCallback for action handlers in a list of memoized items.

import React, { useState, memo, useCallback } from 'react';

const Item = memo(function Item({ item, onIncrementScore }) {
console.log(`Item rendered: ${item.name}`);
return (
<div style={{ border: '1px solid #eee', padding: '10px', margin: '5px' }}>
<h3>{item.name} (Score: {item.score})</h3>
<button onClick={onIncrementScore}>Increment Score</button>
</div>
);
});

const initialItemsData = [
{ id: 'a', name: 'Alpha', score: 0 },
{ id: 'b', name: 'Bravo', score: 0 },
{ id: 'c', name: 'Charlie', score: 0 },
];

function ItemList() {
const [items, setItems] = useState(initialItemsData);
const [listTitle, setListTitle] = useState("Interactive List"); // Unrelated state

// Use useCallback for the increment handler for EACH item
// This requires creating a specific handler for each item's ID, or passing ID to a generic handler.
// Let's create specific handlers for simplicity here, though often you'd pass the ID.

// This pattern is okay if the number of items is small and fixed.
// For dynamic lists, you'd typically pass item.id to a single memoized handler.
const handleIncrementAlpha = useCallback(() => {
setItems(currentItems =>
currentItems.map(it => it.id === 'a' ? { ...it, score: it.score + 1 } : it)
);
}, []); // setItems is stable

const handleIncrementBravo = useCallback(() => {
setItems(currentItems =>
currentItems.map(it => it.id === 'b' ? { ...it, score: it.score + 1 } : it)
);
}, []);

const handleIncrementCharlie = useCallback(() => {
setItems(currentItems =>
currentItems.map(it => it.id === 'c' ? { ...it, score: it.score + 1 } : it)
);
}, []);

// A more common and scalable pattern: a single handler that takes an ID
const handleIncrementGeneric = useCallback((itemId) => {
setItems(currentItems =>
currentItems.map(it => it.id === itemId ? { ...it, score: it.score + 1 } : it)
);
}, []); // setItems is stable


return (
<div>
<input type="text" value={listTitle} onChange={e => setListTitle(e.target.value)} />
<h2>{listTitle}</h2>
{items.map(item => (
<Item
key={item.id}
item={item}
// For the generic handler, you'd need to create a new function in the map,
// which defeats useCallback for that specific prop instance unless the child also memoizes the inline arrow.
// e.g., onIncrementScore={() => handleIncrementGeneric(item.id)}
// This specific inline arrow function would be a new reference each time.
// This is a common challenge. Solutions include:
// 1. Child component calls a prop with an ID: onClick={() => props.onIncrementById(item.id)}
// 2. Pass the ID as a separate prop and the generic handler.
// For this example, let's use specific handlers to show useCallback's effect.
onIncrementScore={
item.id === 'a' ? handleIncrementAlpha :
item.id === 'b' ? handleIncrementBravo :
handleIncrementCharlie
}
/>
))}
<p><em>Using specific handlers for each item. If using a generic handler like `handleIncrementGeneric`,
passing `() => handleIncrementGeneric(item.id)` to `onIncrementScore` would create a new function reference
in each `Item`'s render. This would negate `useCallback` for `handleIncrementGeneric` unless the `Item`
component itself was more complex or did further memoization.
The current setup with specific handlers ensures each `Item` gets a stable function reference.</em></p>
</div>
);
}

export default ItemList;

Walkthrough and Explanation:

  1. Item Component: Wrapped in React.memo. It receives item data and an onIncrementScore callback.
  2. ItemList Component:
    • Manages items state and an unrelated listTitle state.
    • handleIncrementAlpha, handleIncrementBravo, handleIncrementCharlie: These are specific event handlers for each item, each wrapped in useCallback with an empty dependency array (since setItems is stable). This ensures each of these functions has a stable reference.
    • handleIncrementGeneric: A more scalable approach where a single function takes an itemId. This function is also memoized.
    • Mapping and Prop Passing:
      • When rendering, we map items. The example shows assigning the specific memoized handler.
      • Challenge with Generic Handlers: If we used handleIncrementGeneric, passing () => handleIncrementGeneric(item.id) to the onIncrementScore prop of Item would create a new inline arrow function reference for each item on every render of ItemList. This would make the onIncrementScore prop unstable, causing Item to re-render even if handleIncrementGeneric itself is memoized.
      • Solutions to Generic Handler Challenge (for Part 2 of useCallback or advanced scenarios):
        • Pass itemId as a separate prop and let Item call props.onIncrementGeneric(props.itemId).
        • The child Item could take onIncrementGeneric and itemId, and internally create its own memoized handler if needed, or just call props.onIncrementGeneric(props.itemId).
  3. Optimization in Action (with specific handlers as coded):
    • When you change the listTitle input, ItemList re-renders.
    • However, since handleIncrementAlpha, etc., are memoized and their references are stable, and assuming item data for other items hasn't changed, those other Item components will not re-render. Only the Item whose score actually changed (or no items if only title changed) would re-render.
    • The console logs for "Item rendered..." will demonstrate this selective re-rendering.

This project highlights how useCallback is essential for event handlers passed to lists of memoized components. The nuance of handling parameters (like itemId) often requires careful structuring to maintain the benefits of memoization.


🔬 Section 4: useCallback and useEffect – Stable Function Dependencies

Just as useMemo is used to stabilize object/array dependencies for useEffect, useCallback is used to stabilize function dependencies.

If an useEffect hook includes a function from the component scope in its dependency array, and that function is redefined on every render, the effect will re-run on every render.

Problematic useEffect with function dependency:

function TimerComponent() {
const [seconds, setSeconds] = useState(0);

// logCurrentTime is new on every render
const logCurrentTime = () => {
console.log(`Current time: ${new Date().toLocaleTimeString()}, Seconds: ${seconds}`);
};

useEffect(() => {
logCurrentTime(); // Call the function
const intervalId = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(intervalId);
}, [logCurrentTime]); // 🚩 Effect re-runs on every render because logCurrentTime is always new

return <p>Timer: {seconds}s (Check console for effect logs)</p>;
}

Here, useEffect will re-run (and clear/set interval) every second because logCurrentTime is a new function reference each time TimerComponent re-renders due to seconds changing.

Solution with useCallback:

// code-block-2.jsx
// Using useCallback to stabilize a function dependency for useEffect.

import React, { useState, useEffect, useCallback } from 'react';

function StableEffectTimer() {
const [seconds, setSeconds] = useState(0);

// Memoize logCurrentTime
const logCurrentTime = useCallback(() => {
// 'seconds' is a dependency because the function's output/behavior depends on it.
console.log(`Current time: ${new Date().toLocaleTimeString()}, Seconds: ${seconds}`);
}, [seconds]);

useEffect(() => {
console.log("Effect setup/re-run.");
logCurrentTime(); // Call the memoized function

const intervalId = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);

return () => {
console.log("Effect cleanup.");
clearInterval(intervalId);
};
}, [logCurrentTime]); // ✅ Effect now only re-runs if logCurrentTime reference changes (i.e., when 'seconds' changes)

return (
<div>
<p>Stable Effect Timer: {seconds}s</p>
<p><em>Open console. "Effect setup/re-run" should log when `seconds` changes because `logCurrentTime` changes.
If `logCurrentTime` had no dependencies (e.g., `[]`), the effect would run only once on mount.</em></p>
</div>
);
}
export default StableEffectTimer;

By wrapping logCurrentTime in useCallback with seconds as a dependency:

  • logCurrentTime reference only changes when seconds changes.
  • useEffect now correctly re-runs only when logCurrentTime (and thus seconds) changes. This makes the effect's behavior more predictable and often more correct. If logCurrentTime had an empty dependency array [] (and didn't use seconds in its body), the effect would only run on mount and cleanup on unmount.

✨ Section 5: Best Practices and Anti-Patterns (Initial Look for Part 1)

Best Practices:

  • Use with React.memo: The most common and clear benefit of useCallback is when passing callbacks to child components that are optimized with React.memo.
    const memoizedHandler = useCallback(() => { /* ... */ }, [deps]);
    return <MemoizedChild onClick={memoizedHandler} />;
  • Stable Dependencies for Hooks: Use useCallback for functions passed as dependencies to useEffect, useMemo, or even other useCallback hooks, if those functions are defined within the component scope and would otherwise change reference on every render.
  • Correct Dependency Array: Just like useMemo and useEffect, useCallback must have a correct dependency array. Include all values from the component scope that are used inside the callback and could change over time. The ESLint plugin eslint-plugin-react-hooks is invaluable here.

Anti-Patterns (What to Avoid):

  • Wrapping Every Function: useCallback has a small cost. Wrapping functions that are not passed to memoized children or not used as hook dependencies is often unnecessary premature optimization and adds boilerplate.
    // Likely unnecessary if MyComponent is not memoized or this isn't a hook dependency
    const handleClick = useCallback(() => { console.log('clicked'); }, []);
    return <button onClick={handleClick}>Click</button>;
  • Empty Dependency Array When It's Incorrect: If your callback uses state or props, but you provide [] as dependencies, your callback will be stale (it will close over the initial values of that state/props).
    // Bad: `count` will be stale inside handleLog if `[]` is used
    const [count, setCount] = useState(0);
    const handleLog = useCallback(() => { console.log(count); }, []); // Should be [count]

💡 Conclusion & Key Takeaways

You've now learned the fundamentals of useCallback and its primary role in providing stable function references. This is crucial for making React.memo effective and for ensuring predictable behavior in hooks like useEffect.

Let's summarize the key takeaways for this Part 1:

  • Problem: Functions defined in components get new references on each render, breaking React.memo optimizations for children receiving them as props.
  • useCallback(fn, deps): Returns a memoized version of fn that only changes if deps change.
  • Main Use Cases:
    1. Passing stable callbacks to React.memo-wrapped child components.
    2. Providing stable function dependencies to useEffect or other hooks.
  • Dependency Management is Key: Accurate dependencies are vital for useCallback to work correctly and avoid stale closures.

Challenge Yourself: Take a parent component that passes an event handler to a child component. Wrap the child in React.memo. Add some unrelated state to the parent to make it re-render. Observe (via console.log in the child) that the child re-renders. Now, apply useCallback to the event handler in the parent. Does the child now correctly skip re-rendering? Experiment with the dependency array of useCallback.


➡️ Next Steps

Understanding useCallback is a significant step in mastering React performance optimization.

In the next article, "useCallback for Memoizing Functions (Part 2): A practical example of using useCallback.", we'll explore more practical examples, advanced scenarios, and further nuances of using useCallback effectively, including its interaction with custom hooks and handling event handlers in dynamic lists.

Keep practicing, and these optimization techniques will become second nature!


glossary

  • Callback Function: A function passed as an argument to another function, which is then invoked (called back) at a later time or in response to an event.
  • Memoized Function: A version of a function whose reference is preserved across renders unless its dependencies change, thanks to useCallback.
  • Stale Closure: When a memoized callback (often due to an incorrect/empty dependency array) "closes over" outdated values of state or props from the render in which it was created, leading to bugs.

Further Reading