Back to Blog

React Performance: useMemo vs useCallback - When and How to Use Them

Master React performance optimization with useMemo and useCallback. Learn when to use each hook, avoid common pitfalls, and measure real performance gains with practical examples and benchmarks.

13 min read
By Perf Lens Team

Introduction

React's useMemo and useCallback hooks are powerful tools for optimizing component performance, but they're also among the most misused hooks in React. Many developers apply them everywhere, thinking more memoization equals better performance—but that's not always true.

In fact, incorrect use of useMemo and useCallback can hurt performance more than help it.

In this comprehensive guide, we'll:

  • Understand how useMemo and useCallback work
  • Learn when to use (and when NOT to use) each hook
  • Compare performance with real benchmarks
  • Avoid common memoization pitfalls
  • Test everything using Perf Lens for accurate measurements

By the end of this article, you'll know exactly when memoization improves performance and when it's just overhead.


Understanding React Re-renders

Before diving into memoization, let's understand why components re-render.

When Do Components Re-render?

A React component re-renders when:

  • 1. State changes** (useState, useReducer)
  • 2. Props change** (parent passed new props)
  • 3. Parent re-renders** (even if props didn't change!)
  • 4. Context changes** (useContext value updated)

The Re-render Problem

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

  // Child re-renders even though its props didn't change!
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <Child name="Alice" />
    </div>
  );
}

function Child({ name }) {
  console.log('Child rendered'); // Logs on every Parent render!
  return <div>Hello, {name}</div>;
}

Problem: When count changes, Parent re-renders, which causes Child to re-render even though name prop didn't change.


Understanding useMemo

What is useMemo?

useMemo memoizes (caches) the result of a computation and only recalculates when dependencies change.

Syntax:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

Basic Example

❌ Without useMemo (Expensive Re-computation):

function ExpensiveComponent({ items }) {
  // This runs on EVERY render (even if items didn't change!)
  const sortedItems = items.sort((a, b) => a.value - b.value);

  return (
    <ul>
      {sortedItems.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
}

✅ With useMemo (Computed Only When Needed):

function ExpensiveComponent({ items }) {
  // Only re-sorts when items array changes
  const sortedItems = useMemo(
    () => items.sort((a, b) => a.value - b.value),
    [items]
  );

  return (
    <ul>
      {sortedItems.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
}

Understanding useCallback

What is useCallback?

useCallback memoizes (caches) a function itself to prevent creating new function instances on every render.

Syntax:

const memoizedCallback = useCallback(() => doSomething(a, b), [a, b]);

Why Does This Matter?

In JavaScript, functions are objects. Each time you create a function, it's a new object reference.

// These are NOT equal (different references)
const fn1 = () => console.log('hello');
const fn2 = () => console.log('hello');
console.log(fn1 === fn2); // false

This matters for child component re-renders!


Basic Example

❌ Without useCallback (New Function on Every Render):

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

  // New function created on every render!
  const handleClick = () => {
    console.log('Clicked');
  };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      {/* Child re-renders even though handleClick logic didn't change */}
      <Child onClick={handleClick} />
    </div>
  );
}

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

✅ With useCallback (Same Function Reference):

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

  // Same function reference across renders
  const handleClick = useCallback(() => {
    console.log('Clicked');
  }, []); // Empty deps = never changes

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      {/* Child doesn't re-render! */}
      <Child onClick={handleClick} />
    </div>
  );
}

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

useMemo vs useCallback: Key Differences

AspectuseMemouseCallback
PurposeMemoize computed valueMemoize function
ReturnsResult of functionFunction itself
Use CaseExpensive calculationsCallback props to child components
ExampleuseMemo(() => a + b, [a, b])useCallback(() => fn(), [])

Equivalence

These are equivalent:

// useCallback version
const fn = useCallback(() => doSomething(), []);

// useMemo version (returns function)
const fn = useMemo(() => () => doSomething(), []);

Tip: Think of useCallback(fn, deps) as useMemo(() => fn, deps).


When to Use useMemo

✅ Use useMemo When:

1. Expensive Computations

function DataTable({ data }) {
  // Filtering/sorting large datasets
  const processedData = useMemo(() => {
    return data
      .filter(item => item.active)
      .sort((a, b) => a.name.localeCompare(b.name))
      .map(item => ({
        ...item,
        displayName: item.firstName + ' ' + item.lastName
      }));
  }, [data]);

  return <Table data={processedData} />;
}

2. Referential Equality for Child Props

function Parent() {
  const [filter, setFilter] = useState('all');

  // Without useMemo, new object on every render
  const filterConfig = useMemo(() => ({
    type: filter,
    caseSensitive: false,
    matchMode: 'contains'
  }), [filter]);

  // Child only re-renders when filter changes
  return <FilteredList config={filterConfig} />;
}

const FilteredList = React.memo(({ config }) => {
  // Expensive rendering logic
});

3. Avoid Re-creating Complex Objects

function Chart({ data }) {
  // Chart options are complex and don't change often
  const chartOptions = useMemo(() => ({
    responsive: true,
    plugins: {
      legend: { position: 'top' },
      tooltip: { mode: 'index' }
    },
    scales: {
      x: { display: true },
      y: { display: true, beginAtZero: true }
    }
  }), []); // Empty deps = never changes

  return <ChartComponent data={data} options={chartOptions} />;
}

❌ Don't Use useMemo When:

1. Cheap Computations

// ❌ Unnecessary (addition is cheap!)
const sum = useMemo(() => a + b, [a, b]);

// ✅ Just do it directly
const sum = a + b;

Rule of Thumb: If the computation takes less than 1ms, don't memoize.


2. Primitives (strings, numbers, booleans)

// ❌ Unnecessary (primitives are compared by value)
const name = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

// ✅ Just do it directly
const name = `${firstName} ${lastName}`;

3. Dependencies Change on Every Render

// ❌ Pointless (deps always change, so always recalculates)
const result = useMemo(() => compute(Math.random()), [Math.random()]);

// ✅ Just do it directly
const result = compute(Math.random());

When to Use useCallback

✅ Use useCallback When:

1. Passing Callbacks to Memoized Children

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

  const handleDelete = useCallback((id) => {
    // API call to delete item
    deleteItem(id);
  }, []); // No dependencies

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      {/* ItemList won't re-render when count changes */}
      <ItemList onDelete={handleDelete} />
    </div>
  );
}

const ItemList = React.memo(({ onDelete }) => {
  // Expensive rendering (100s of items)
  return <div>{/* ... */}</div>;
});

2. Dependencies in Other Hooks

function SearchComponent() {
  const [query, setQuery] = useState('');

  // Stable function reference for useEffect
  const search = useCallback(() => {
    fetch(`/api/search?q=${query}`)
      .then(r => r.json())
      .then(setResults);
  }, [query]);

  useEffect(() => {
    const timer = setTimeout(search, 500);
    return () => clearTimeout(timer);
  }, [search]); // Won't cause infinite loop!

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

3. Custom Hooks Returning Functions

function useApi() {
  const [loading, setLoading] = useState(false);

  // Stable API function for consumers
  const fetchData = useCallback(async (endpoint) => {
    setLoading(true);
    const response = await fetch(endpoint);
    const data = await response.json();
    setLoading(false);
    return data;
  }, []);

  return { fetchData, loading };
}

❌ Don't Use useCallback When:

1. Not Passed to Child Components

// ❌ Unnecessary (not passed to children)
const handleClick = useCallback(() => {
  console.log('Clicked');
}, []);

return <button onClick={handleClick}>Click</button>;

// ✅ Just define normally
const handleClick = () => console.log('Clicked');
return <button onClick={handleClick}>Click</button>;

2. Child is Not Memoized

// ❌ Pointless (Child not using React.memo)
const handleClick = useCallback(() => {
  doSomething();
}, []);

return <Child onClick={handleClick} />; // Child not memoized!

// ✅ Either remove useCallback or add React.memo to Child

Performance Benchmarks

Benchmark 1: useMemo with Expensive Computation

// Test setup: Sorting 10,000 items
const largeArray = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  value: Math.random()
}));

// Without useMemo
function WithoutMemo() {
  const [count, setCount] = useState(0);
  const sorted = largeArray.sort((a, b) => a.value - b.value);
  return <div>Count: {count}</div>;
}

// With useMemo
function WithMemo() {
  const [count, setCount] = useState(0);
  const sorted = useMemo(
    () => largeArray.sort((a, b) => a.value - b.value),
    []
  );
  return <div>Count: {count}</div>;
}

Results (10 re-renders):

VersionTotal TimePer Render
Without useMemo450 ms45 ms
With useMemo48 ms4.8 ms

Improvement: 90% faster re-renders!


Benchmark 2: useCallback with Child Components

// Test setup: 100 child components
function WithoutCallback() {
  const [count, setCount] = useState(0);
  const handleClick = () => console.log('click');

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      {Array.from({ length: 100 }, (_, i) => (
        <MemoizedChild key={i} onClick={handleClick} />
      ))}
    </div>
  );
}

function WithCallback() {
  const [count, setCount] = useState(0);
  const handleClick = useCallback(() => console.log('click'), []);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      {Array.from({ length: 100 }, (_, i) => (
        <MemoizedChild key={i} onClick={handleClick} />
      ))}
    </div>
  );
}

const MemoizedChild = React.memo(({ onClick }) => {
  return <button onClick={onClick}>Click</button>;
});

Results (parent re-renders):

VersionRender TimeChild Re-renders
Without useCallback180 ms100 children
With useCallback12 ms0 children

Improvement: 93% faster with 100+ children!


Common Pitfalls and Solutions

Pitfall 1: Missing Dependencies

// ❌ Bug: stale closure
function Counter() {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => {
    setCount(count + 1); // Always uses initial count (0)!
  }, []); // Missing 'count' dependency

  return <button onClick={increment}>Count: {count}</button>;
}

// ✅ Fix 1: Add dependency
const increment = useCallback(() => {
  setCount(count + 1);
}, [count]);

// ✅ Fix 2: Use functional update (best!)
const increment = useCallback(() => {
  setCount(prev => prev + 1); // No dependency needed!
}, []);

Pitfall 2: Memoizing Everything

// ❌ Over-memoization (adds overhead!)
function SimpleComponent({ name }) {
  const greeting = useMemo(() => `Hello, ${name}`, [name]); // Unnecessary!
  const handleClick = useCallback(() => {
    console.log(greeting);
  }, [greeting]); // Unnecessary!

  return <button onClick={handleClick}>{greeting}</button>;
}

// ✅ Simpler and faster
function SimpleComponent({ name }) {
  const greeting = `Hello, ${name}`;
  const handleClick = () => console.log(greeting);
  return <button onClick={handleClick}>{greeting}</button>;
}

Rule: Only memoize when you measure a performance problem!


Pitfall 3: Incorrect Dependency Arrays

// ❌ Object dependency (always new reference)
const config = { theme: 'dark', size: 'large' };
const memoized = useMemo(() => compute(config), [config]); // Always recalculates!

// ✅ Primitive dependencies
const { theme, size } = config;
const memoized = useMemo(() => compute({ theme, size }), [theme, size]);

Pitfall 4: Forgetting React.memo

// ❌ useCallback without React.memo (no benefit!)
function Parent() {
  const handleClick = useCallback(() => {}, []);
  return <Child onClick={handleClick} />; // Child not memoized!
}

function Child({ onClick }) {
  return <button onClick={onClick}>Click</button>;
}

// ✅ Add React.memo to Child
const Child = React.memo(({ onClick }) => {
  return <button onClick={onClick}>Click</button>;
});

Real-World Example: Optimized Data Table

function DataTable({ data, filters }) {
  // Expensive: Filter and sort data
  const processedData = useMemo(() => {
    let result = data;

    // Apply filters
    if (filters.search) {
      result = result.filter(item =>
        item.name.toLowerCase().includes(filters.search.toLowerCase())
      );
    }

    // Sort
    result = result.sort((a, b) => {
      const aValue = a[filters.sortBy];
      const bValue = b[filters.sortBy];
      return filters.sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
    });

    return result;
  }, [data, filters.search, filters.sortBy, filters.sortOrder]);

  // Stable callbacks for child components
  const handleSort = useCallback((column) => {
    setFilters(prev => ({
      ...prev,
      sortBy: column,
      sortOrder: prev.sortBy === column && prev.sortOrder === 'asc' ? 'desc' : 'asc'
    }));
  }, []);

  const handleDelete = useCallback((id) => {
    deleteItem(id);
  }, []);

  return (
    <table>
      <TableHeader onSort={handleSort} />
      <TableBody data={processedData} onDelete={handleDelete} />
    </table>
  );
}

// Memoized child components
const TableHeader = React.memo(({ onSort }) => {
  // ... header rendering
});

const TableBody = React.memo(({ data, onDelete }) => {
  // ... body rendering
});

Performance:

  • Processing 10,000 rows: 50ms → 2ms (96% faster)
  • Child re-renders: 100% → 0% (only when needed)

Decision Framework

Should I use useMemo?

Is the computation expensive (>5ms)?
  Yes → Does it run on every render?
    Yes → Do dependencies change infrequently?
      Yes → ✅ Use useMemo
      No → ❌ Don't use useMemo
    No → ❌ Don't use useMemo
  No → ❌ Don't use useMemo

Should I use useCallback?

Is the function passed to a child component?
  Yes → Is the child memoized (React.memo)?
    Yes → Does the parent re-render frequently?
      Yes → ✅ Use useCallback
      No → ❌ Don't use useCallback
    No → ❌ Don't use useCallback
  No → Is it used in useEffect/useMemo deps?
    Yes → ✅ Use useCallback
    No → ❌ Don't use useCallback

Testing Performance

Use Perf Lens to measure the impact of memoization.

Test Case: Memoization Overhead

// Test 1: No memoization
function NoMemo() {
  const value = expensiveComputation();
  return <div>{value}</div>;
}

// Test 2: With useMemo
function WithMemo() {
  const value = useMemo(() => expensiveComputation(), []);
  return <div>{value}</div>;
}

// Compare render times across 100 renders

Best Practices

✅ DO:

  • Measure before optimizing (use React DevTools Profiler)
  • Memoize expensive computations (>5ms)
  • Use useCallback for callbacks passed to memoized children
  • Use functional updates to avoid dependencies
  • Keep dependency arrays accurate (ESLint plugin)

❌ DON'T:

  • Memoize everything "just in case"
  • Use useMemo for cheap operations
  • Forget React.memo when using useCallback
  • Ignore dependency warnings
  • Memoize at the expense of readability

Conclusion

useMemo and useCallback are powerful optimization tools, but they come with overhead. Use them strategically when you've identified actual performance problems.

Key Takeaways:

  • 1. useMemo**: Memoize expensive computed values
  • 2. useCallback**: Memoize functions passed to memoized children
  • 3. React.memo**: Required for useCallback to be effective
  • 4. Measure first**: Don't optimize prematurely
  • 5. Overhead exists**: Memoization isn't free

Performance Impact:

  • Expensive computations: 50-95% faster with useMemo
  • Many child components: 80-95% fewer re-renders with useCallback
  • Simple operations: Memoization can be 10-20% slower (overhead)

Remember:

"Premature optimization is the root of all evil" - Donald Knuth

Only memoize when profiling shows it helps!


Further Reading


Want to benchmark your React components? Use Perf Lens to measure rendering performance and optimize with confidence!

React Performance: useMemo vs useCallback - When and How to Use Them | Perf Lens