useCallback and useMemo in React: What They Do, How They Work & Best Use Cases [Visual Codepen Example]

React’s declarative nature makes building UIs intuitive, but as applications grow, performance bottlenecks can emerge—especially around unnecessary re-renders and expensive computations. Two hooks, useCallback and useMemo, are designed to optimize these scenarios by leveraging memoization (caching values to avoid redundant work).

If you’ve ever wondered why a child component re-renders unexpectedly or why a heavy calculation slows down your app on every state change, this guide will demystify useCallback and useMemo. We’ll break down how they work, their key differences, real-world use cases, and even walk through a live Codepen example to see them in action.

Table of Contents#

  1. What is React Re-rendering?
  2. Understanding useCallback
  3. Understanding useMemo
  4. useCallback vs. useMemo: Key Differences
  5. Visual Codepen Example
  6. Best Practices
  7. Common Pitfalls to Avoid
  8. When Not to Use useCallback or useMemo
  9. Conclusion
  10. References

What is React Re-rendering?#

Before diving into useCallback and useMemo, it’s critical to understand React’s re-rendering behavior. A React component re-renders when:

  • Its own state changes.
  • Its parent component re-renders (unless the child is optimized with React.memo or similar).
  • Its props change (based on reference equality, not value equality).

By default, React re-renders components frequently to keep the UI in sync with state/props. While this is efficient for small apps, unnecessary re-renders or repeated expensive computations can degrade performance. This is where memoization hooks come in.

Understanding useCallback#

What is useCallback?#

useCallback is a React hook that memoizes functions. In React, functions defined inside a component are recreated on every render. If you pass such a function as a prop to a child component, the child may re-render unnecessarily—even if the function’s logic hasn’t changed—because the function reference is new each time.

useCallback solves this by returning a memoized version of the function that only changes if one of its dependencies updates. This preserves the function reference across re-renders, preventing unnecessary child re-renders.

How useCallback Works#

Syntax:

const memoizedCallback = useCallback(
  () => {
    // Function logic here
  },
  [dependencies], // Array of values the function depends on
);  
  • First argument: The function you want to memoize.
  • Second argument: A dependency array (like useEffect). The memoized function updates only when a dependency changes.
  • Return value: The memoized function.

Example: Without useCallback#

Consider a parent component passing a callback to a child component wrapped in React.memo (which prevents re-renders if props are unchanged):

import { memo, useState } from 'react';
 
// Memoized child component
const Child = memo(({ onClick }) => {
  console.log("Child re-rendered");
  return <button onClick={onClick}>Click me</button>;
});
 
// Parent component
const Parent = () => {
  const [count, setCount] = useState(0);
 
  // This function is recreated on every render!
  const handleClick = () => {
    console.log("Button clicked");
  };
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment Count</button>
      <Child onClick={handleClick} />
    </div>
  );
};  

Problem: Even though Child is memoized, clicking "Increment Count" (which updates count) causes Parent to re-render. Since handleClick is recreated on every render, Child receives a new onClick prop and re-renders unnecessarily ("Child re-rendered" logs on every count increment).

Example: With useCallback#

Add useCallback to memoize handleClick:

import { memo, useState, useCallback } from 'react';
 
const Child = memo(({ onClick }) => {
  console.log("Child re-rendered");
  return <button onClick={onClick}>Click me</button>;
});
 
const Parent = () => {
  const [count, setCount] = useState(0);
 
  // Memoize handleClick: only re-created if dependencies change (none here)
  const handleClick = useCallback(() => {
    console.log("Button clicked");
  }, []); // Empty dependency array: function never changes
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment Count</button>
      <Child onClick={handleClick} />
    </div>
  );
};  

Solution: Now, handleClick is memoized and only created once (since the dependency array is empty). When Parent re-renders (due to count changing), Child receives the same onClick reference and does not re-render ("Child re-rendered" only logs once, on initial render).

Common Use Cases for useCallback#

  1. Passing Callbacks to Memoized Child Components (as in the example above).

  2. Callbacks in Hook Dependencies (e.g., useEffect): If a function is a dependency of useEffect, memoizing it with useCallback prevents the effect from re-running unnecessarily.

    useEffect(() => {
      const interval = setInterval(handleTick, 1000);
      return () => clearInterval(interval);
    }, [handleTick]); // Without useCallback, handleTick changes on every render, causing the effect to re-run.  
  3. Optimizing Custom Hooks that return functions (e.g., a useApi hook returning a fetchData function).

Understanding useMemo#

What is useMemo?#

While useCallback memoizes functions, useMemo memoizes the result of expensive calculations. In React, expensive operations (e.g., sorting a large list, filtering data, or complex computations) run on every render by default, which can slow down your app.

useMemo solves this by caching the result of the calculation and only re-computing it when one of its dependencies changes. This ensures expensive work runs only when necessary.

How useMemo Works#

Syntax:

const memoizedValue = useMemo(
  () => {
    // Expensive calculation here
    return result;
  },
  [dependencies], // Re-compute only if dependencies change
);  
  • First argument: A function that performs the expensive calculation and returns a value.
  • Second argument: A dependency array. The memoized value updates only when a dependency changes.
  • Return value: The memoized result of the calculation.

Example: Without useMemo#

Consider a component that filters a large list of items on every render:

import { useState } from 'react';
 
const ExpensiveComponent = () => {
  const [searchTerm, setSearchTerm] = useState("");
  const [items] = useState(Array.from({ length: 10000 }, (_, i) => `Item ${i}`)); // Large list
 
  // Expensive: runs on EVERY render (even if searchTerm doesn't change!)
  const filteredItems = items.filter(item => 
    item.toLowerCase().includes(searchTerm.toLowerCase())
  );
 
  return (
    <div>
      <input
        type="text"
        placeholder="Search..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />
      <ul>
        {filteredItems.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
};  

Problem: Filtering 10,000 items is computationally expensive. Even if searchTerm doesn’t change (e.g., the user is typing but deletes the input, leaving searchTerm empty), filteredItems re-runs on every render, causing lag.

Example: With useMemo#

Add useMemo to memoize the filtered result:

import { useState, useMemo } from 'react';
 
const ExpensiveComponent = () => {
  const [searchTerm, setSearchTerm] = useState("");
  const [items] = useState(Array.from({ length: 10000 }, (_, i) => `Item ${i}`));
 
  // Memoize the filtered result: only re-compute when searchTerm or items change
  const filteredItems = useMemo(() => {
    console.log("Filtering items..."); // Logs only when searchTerm changes
    return items.filter(item => 
      item.toLowerCase().includes(searchTerm.toLowerCase())
    );
  }, [searchTerm, items]); // Re-run only if searchTerm or items change
 
  return (
    <div>
      <input
        type="text"
        placeholder="Search..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />
      <ul>
        {filteredItems.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
};  

Solution: filteredItems now re-computes only when searchTerm or items changes. Typing in the search box triggers the filter, but if searchTerm is unchanged (e.g., re-renders from other state changes), the cached result is reused, improving performance.

Common Use Cases for useMemo#

  1. Expensive Calculations (sorting, filtering large datasets, mathematical computations).
  2. Memoizing Object/Array Props (to prevent memoized child components from re-rendering). For example:
    // Without useMemo: new object on every render → child re-renders
    const user = { name: "Alice", age: 30 };
     
    // With useMemo: memoized object → child only re-renders if name/age change
    const user = useMemo(() => ({ name: "Alice", age: 30 }), [name, age]);  
  3. Derived State (state computed from existing state/props that’s expensive to generate).

useCallback vs. useMemo: Key Differences#

FeatureuseCallbackuseMemo
PurposeMemoizes functionsMemoizes values (results of calculations)
ReturnsA memoized functionA memoized value
Use CasePreventing re-renders of memoized children (via stable function references).Avoiding redundant expensive calculations.
Analogy"Cache this function so it doesn’t change.""Cache the result of this calculation so it doesn’t re-run."

Simpler way to remember:

  • Use useCallback when you need a stable function reference.
  • Use useMemo when you need a stable value from an expensive calculation.

Visual Codepen Example#

Let’s combine both hooks in a live example to see their impact. We’ll build a component with:

  • A memoized child component that receives a callback and an object prop.
  • An expensive calculation (filtering a large list).

Codepen Link: useCallback & useMemo Demo

Code Walkthrough#

import { useState, useCallback, useMemo, memo } from 'react';
import { createRoot } from 'react-dom/client';
 
// Memoized Child Component
const Child = memo(({ onButtonClick, user }) => {
  console.log("Child re-rendered");
  return (
    <div className="child">
      <h3>Child Component</h3>
      <p>User: {user.name} (Age: {user.age})</p>
      <button onClick={onButtonClick}>Click Child Button</button>
    </div>
  );
});
 
// Parent Component
const Parent = () => {
  const [count, setCount] = useState(0);
  const [search, setSearch] = useState("");
  const [userAge, setUserAge] = useState(30);
 
  // 1. Memoize callback with useCallback (stable reference)
  const handleChildClick = useCallback(() => {
    alert("Child button clicked!");
  }, []); // No dependencies → never re-created
 
  // 2. Memoize user object with useMemo (stable reference)
  const user = useMemo(() => ({
    name: "John Doe",
    age: userAge
  }), [userAge]); // Re-create only if userAge changes
 
  // 3. Memoize expensive filter with useMemo
  const filteredItems = useMemo(() => {
    console.log("Filtering items...");
    const largeList = Array.from({ length: 10000 }, (_, i) => `Item ${i}`);
    return largeList.filter(item => 
      item.toLowerCase().includes(search.toLowerCase())
    );
  }, [search]); // Re-filter only if search changes
 
  return (
    <div className="parent">
      <h2>Parent Component</h2>
      <p>Count: {count}</p>
      <button onClick={() => setCount(prev => prev + 1)}>Increment Count</button>
      
      <p>Search Items: <input 
        type="text" 
        value={search} 
        onChange={(e) => setSearch(e.target.value)} 
        placeholder="Type to filter..."
      /></p>
      
      <p>User Age: {userAge} <button onClick={() => setUserAge(prev => prev + 1)}>Increment Age</button></p>
      
      <Child onButtonClick={handleChildClick} user={user} />
      
      <div className="filtered-items">
        <h4>Filtered Items ({filteredItems.length}):</h4>
        <ul>{filteredItems.slice(0, 5).map((item, i) => <li key={i}>{item}</li>)}</ul>
        {filteredItems.length > 5 && <p>...and {filteredItems.length - 5} more</p>}
      </div>
    </div>
  );
};
 
// Render to DOM
const root = createRoot(document.getElementById('root'));
root.render(<Parent />);

Key Observations#

  • Without useCallback/useMemo:

    • The Child component re-renders on every count increment (due to new handleChildClick and user references).
    • "Filtering items..." logs on every render (even if search is unchanged).
  • With useCallback/useMemo:

    • Child only re-renders when userAge changes (since user is memoized) or if handleChildClick dependencies change (none here).
    • "Filtering items..." only logs when search changes.

Best Practices#

  1. Don’t Prematurely Optimize
    Memoization has overhead (storing cached values/functions). Only use these hooks when you observe performance issues (e.g., laggy renders, unnecessary re-renders).

  2. Keep Dependency Arrays Accurate
    Always include all variables used inside the memoized function/calculation in the dependency array. Missing dependencies can lead to stale values (e.g., using outdated state).

  3. Avoid Side Effects in useMemo
    The function passed to useMemo runs during rendering, so it shouldn’t perform side effects (e.g., API calls). Use useEffect for side effects instead.

  4. Combine with React.memo
    useCallback and useMemo are most effective when used with React.memo (for components) or useMemo (for object/array props) to prevent unnecessary re-renders.

  5. Profile First
    Use React DevTools’ Profiler tab to identify re-render issues or expensive calculations before optimizing with these hooks.

Common Pitfalls to Avoid#

  • Over-Memoizing Everything
    Memoizing trivial functions or cheap calculations wastes memory and can make code harder to read.

  • Ignoring Dependency Arrays
    Forgetting dependencies (e.g., useCallback(() => { /* uses count */ }, [])) leads to stale closures, where the memoized function uses outdated state/props.

  • Using useMemo for Side Effects
    useMemo is for calculations, not side effects. For example:

    // ❌ Bad: side effect in useMemo
    useMemo(() => {
      document.title = `Count: ${count}`; // Side effect!
    }, [count]);
     
    // ✅ Good: use useEffect for side effects
    useEffect(() => {
      document.title = `Count: ${count}`;
    }, [count]);  
  • Assuming Memoization Guarantees No Re-renders
    React.memo (and memoized props) only prevent re-renders if all props are shallowly equal. If other props change, the component will still re-render.

When Not to Use useCallback or useMemo#

  • Small Apps/Components with no performance issues.
  • Trivial Functions/Calculations (e.g., a simple () => setCount(1) or [1, 2, 3].map(x => x * 2)).
  • When Dependencies Change Frequently (memoization won’t help if dependencies update on every render).

Conclusion#

useCallback and useMemo are powerful tools for optimizing React performance, but they’re not silver bullets. useCallback stabilizes function references to prevent unnecessary re-renders of memoized child components, while useMemo caches expensive calculation results to avoid redundant work.

The key is to measure first, then optimize. Use React DevTools to identify bottlenecks, and apply these hooks only when they provide a clear performance benefit. With careful use, they’ll help keep your app fast and responsive as it scales.

References#