Reactintermediate

useCallback & useMemo Deep Dive

Master React's memoization hooks: useCallback for stable function references, useMemo for expensive computations, dependency array gotchas, when to memoize, and when NOT to. Performance optimization done right.

14 min readยทPublished Mar 16, 2026
reacthooksusecallbackusememoperformance

Why Memoization Exists in React

Every time a component re-renders, the entire function body executes again. Every variable is recalculated. Every function is recreated. Every object is a new reference.

function ProductList({ products, tax }) {
  // Re-created on EVERY render:
  const calculateTotal = (items) => {
    return items.reduce((sum, item) => sum + item.price, 0) * (1 + tax);
  };

  // Re-computed on EVERY render:
  const total = calculateTotal(products);

  // New function reference on EVERY render:
  const handleSort = () => { /* ... */ };

  return <div>{total}</div>;
}

Most of the time, this is fine. JavaScript is fast. Recreating a function or computing a value takes microseconds. But there are two cases where it matters:

  1. Expensive computations โ€” sorting 10,000 items, complex math, parsing large data
  2. Referential equality โ€” child components use React.memo, or values are useEffect/useMemo dependencies

useMemo and useCallback solve these two problems respectively.

useMemo: Memoizing Values

useMemo caches the result of a computation and only recalculates when its dependencies change.

Syntax

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
  • First argument: a function that returns the value to cache
  • Second argument: dependency array โ€” recalculates when any dependency changes
  • Returns: the cached value (or newly computed value if deps changed)

Example: Expensive Filtering

function ProductCatalog({ products, searchQuery, category }) {
  // Without useMemo: filters 10,000 products on EVERY render
  // With useMemo: only re-filters when products, searchQuery, or category changes
  const filteredProducts = useMemo(() => {
    console.log('Filtering products...');  // See when it actually runs
    return products
      .filter(p => p.category === category)
      .filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
      .sort((a, b) => a.name.localeCompare(b.name));
  }, [products, searchQuery, category]);

  return (
    <ul>
      {filteredProducts.map(p => (
        <li key={p.id}>{p.name} โ€” ${p.price}</li>
      ))}
    </ul>
  );
}

Example: Derived Data

function Dashboard({ transactions }) {
  const stats = useMemo(() => {
    const total = transactions.reduce((sum, t) => sum + t.amount, 0);
    const avg = total / transactions.length || 0;
    const max = Math.max(...transactions.map(t => t.amount), 0);
    const min = Math.min(...transactions.map(t => t.amount), 0);
    const byCategory = transactions.reduce((acc, t) => {
      acc[t.category] = (acc[t.category] || 0) + t.amount;
      return acc;
    }, {});

    return { total, avg, max, min, byCategory };
  }, [transactions]);

  return (
    <div>
      <p>Total: ${stats.total.toFixed(2)}</p>
      <p>Average: ${stats.avg.toFixed(2)}</p>
      <p>Range: ${stats.min} โ€” ${stats.max}</p>
    </div>
  );
}

How useMemo Works

Render 1: useMemo(fn, [A, B])
  -> Calls fn(), stores result as cache_1
  -> Returns cache_1

Render 2: useMemo(fn, [A, B])    (deps unchanged)
  -> Skips fn()
  -> Returns cache_1              (same cached value)

Render 3: useMemo(fn, [A, C])    (B changed to C)
  -> Calls fn(), stores result as cache_2
  -> Returns cache_2

Render 4: useMemo(fn, [A, C])    (deps unchanged)
  -> Skips fn()
  -> Returns cache_2

useCallback: Memoizing Functions

useCallback caches a function definition and returns the same reference as long as dependencies haven't changed.

Syntax

const memoizedFn = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). The difference is semantic โ€” useCallback is for functions, useMemo is for values.

Why Function References Matter

// Parent re-renders -> new handleDelete function -> Child re-renders (even with memo)
function Parent() {
  const [count, setCount] = useState(0);

  // New function reference every render
  const handleDelete = (id) => {
    console.log('Delete', id);
  };

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <MemoizedChild onDelete={handleDelete} />
      {/* MemoizedChild re-renders because handleDelete is a new reference */}
    </div>
  );
}

const MemoizedChild = React.memo(function Child({ onDelete }) {
  console.log('Child rendered');
  return <button onClick={() => onDelete(1)}>Delete</button>;
});

The Fix

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

  // Same function reference across renders (unless deps change)
  const handleDelete = useCallback((id) => {
    console.log('Delete', id);
  }, []);  // No dependencies โ€” function never changes

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <MemoizedChild onDelete={handleDelete} />
      {/* MemoizedChild skips re-render because handleDelete is the same reference */}
    </div>
  );
}

useCallback with Dependencies

function SearchPage() {
  const [query, setQuery] = useState('');
  const [sortOrder, setSortOrder] = useState('asc');

  // Recreated only when sortOrder changes
  const handleSearch = useCallback((searchQuery) => {
    fetch(`/api/search?q=${searchQuery}&sort=${sortOrder}`)
      .then(res => res.json())
      .then(data => console.log(data));
  }, [sortOrder]);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <select value={sortOrder} onChange={e => setSortOrder(e.target.value)}>
        <option value="asc">Ascending</option>
        <option value="desc">Descending</option>
      </select>
      <SearchResults onSearch={handleSearch} query={query} />
    </div>
  );
}

When to Use (Real Cases)

Case 1: React.memo Child with Callback Props

This is the most common legitimate use case. Without useCallback, React.memo on the child is useless because the function prop changes every render.

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');

  const handleToggle = useCallback((id) => {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    ));
  }, []);

  const handleDelete = useCallback((id) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }, []);

  const filteredTodos = useMemo(() => {
    switch (filter) {
      case 'active': return todos.filter(t => !t.done);
      case 'completed': return todos.filter(t => t.done);
      default: return todos;
    }
  }, [todos, filter]);

  return (
    <div>
      <FilterBar filter={filter} onFilterChange={setFilter} />
      {filteredTodos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={handleToggle}
          onDelete={handleDelete}
        />
      ))}
    </div>
  );
}

// React.memo skips re-render if props haven't changed
const TodoItem = React.memo(function TodoItem({ todo, onToggle, onDelete }) {
  console.log('TodoItem render:', todo.id);
  return (
    <div>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => onToggle(todo.id)}
      />
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)}>X</button>
    </div>
  );
});

Case 2: Dependency of useEffect

When a function is used in a useEffect dependency array, useCallback prevents the effect from re-running unnecessarily.

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  // Without useCallback: new function every render -> effect re-runs every render
  const connect = useCallback(() => {
    const connection = createConnection(roomId);
    connection.on('message', msg => {
      setMessages(prev => [...prev, msg]);
    });
    connection.connect();
    return connection;
  }, [roomId]);

  useEffect(() => {
    const connection = connect();
    return () => connection.disconnect();
  }, [connect]);  // Only re-runs when connect changes (when roomId changes)

  return <MessageList messages={messages} />;
}

Case 3: Expensive List Operations

function DataGrid({ rows, columns }) {
  // Expensive: processes 10,000 rows
  const processedRows = useMemo(() => {
    return rows.map(row => ({
      ...row,
      cells: columns.map(col => ({
        value: col.accessor(row),
        formatted: col.format ? col.format(col.accessor(row)) : col.accessor(row),
      })),
    }));
  }, [rows, columns]);

  const sortedRows = useMemo(() => {
    return [...processedRows].sort((a, b) => {
      // complex multi-column sort
      return a.cells[0].value.localeCompare(b.cells[0].value);
    });
  }, [processedRows]);

  return (
    <table>
      <tbody>
        {sortedRows.map(row => (
          <tr key={row.id}>
            {row.cells.map((cell, i) => (
              <td key={i}>{cell.formatted}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

When NOT to Use (Antipatterns)

Premature memoization is the most common antipattern. Memoization has overhead โ€” React must store the previous value, compare dependencies, and manage the cache. For cheap operations, this overhead can exceed the cost of just recalculating.

Antipattern 1: Memoizing Cheap Operations

// UNNECESSARY โ€” string concatenation is trivially fast
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

// Just calculate it:
const fullName = `${firstName} ${lastName}`;

// UNNECESSARY โ€” simple arithmetic
const total = useMemo(() => price * quantity, [price, quantity]);

// Just calculate it:
const total = price * quantity;

Antipattern 2: useCallback Without React.memo

// POINTLESS โ€” Child is not memoized, so it re-renders anyway
function Parent() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <Child onClick={handleClick} />;
  // Child re-renders because Parent re-rendered, not because of the prop reference
}

function Child({ onClick }) {
  return <button onClick={onClick}>Click</button>;
}
Without React.memo on Child:

  Parent re-renders
    -> Child re-renders (regardless of prop references)
    -> useCallback was wasted effort

With React.memo on Child:

  Parent re-renders
    -> React.memo checks: did props change?
    -> onClick is same reference (useCallback) -> props unchanged
    -> Child SKIPS re-render

Antipattern 3: Memoizing Everything "Just in Case"

// DON'T do this โ€” adds complexity with no benefit
function OverMemoized() {
  const [name, setName] = useState('');

  // Unnecessary โ€” inline functions in JSX are fine for cheap operations
  const handleChange = useCallback((e) => {
    setName(e.target.value);
  }, []);

  // Unnecessary โ€” simple transform
  const displayName = useMemo(() => name.toUpperCase(), [name]);

  // Unnecessary โ€” JSX is already cheap to compute
  const input = useMemo(() => (
    <input value={name} onChange={handleChange} />
  ), [name, handleChange]);

  return <div>{input}<p>{displayName}</p></div>;
}

// CLEAN โ€” no memoization needed
function Clean() {
  const [name, setName] = useState('');

  return (
    <div>
      <input value={name} onChange={e => setName(e.target.value)} />
      <p>{name.toUpperCase()}</p>
    </div>
  );
}

Decision Flowchart

Should I memoize?

  Is the computation expensive?
  (sorting 1000+ items, complex math, heavy processing)
    |
    YES -> useMemo
    NO  -> Is a memoized child receiving this as a prop?
             |
             YES -> Is it a function? -> useCallback
                    Is it an object/array? -> useMemo
             NO  -> Don't memoize. Just calculate it.

Dependency Array Gotchas

Objects and Arrays Create New References

function SearchPage({ defaultFilters }) {
  const [query, setQuery] = useState('');

  // BUG: defaultFilters is an object โ€” if parent re-renders,
  // it might be a new reference even with the same values
  const results = useMemo(() => {
    return search(query, defaultFilters);
  }, [query, defaultFilters]);
  // This re-runs if defaultFilters is a new object (even with same content)
}

// Fix: Destructure to primitive dependencies
function SearchPage({ defaultFilters }) {
  const [query, setQuery] = useState('');
  const { category, minPrice, maxPrice } = defaultFilters;

  const results = useMemo(() => {
    return search(query, { category, minPrice, maxPrice });
  }, [query, category, minPrice, maxPrice]);
  // Only re-runs when actual values change
}

Functions in Dependencies

// BUG: getItems is a new function every render if parent re-renders
function List({ getItems }) {
  const items = useMemo(() => getItems(), [getItems]);
  // Re-computes every render because getItems is new

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

// Fix: Parent should memoize the function
function Parent() {
  const getItems = useCallback(() => {
    return expensiveItemComputation();
  }, []);

  return <List getItems={getItems} />;
}

Changing Dependencies Invalidate the Cache

function ExpensiveComponent({ data, sortField, sortDirection, filterFn }) {
  // If ANY dependency changes, the entire computation re-runs
  const processed = useMemo(() => {
    const filtered = data.filter(filterFn);      // O(n)
    const sorted = [...filtered].sort((a, b) => {
      const cmp = a[sortField].localeCompare(b[sortField]);
      return sortDirection === 'asc' ? cmp : -cmp;
    });
    return sorted;
  }, [data, sortField, sortDirection, filterFn]);

  // Consider: Can you split this into two useMemo calls?
  // Filter once (depends on data, filterFn)
  // Sort separately (depends on filtered, sortField, sortDirection)

  const filtered = useMemo(() => data.filter(filterFn), [data, filterFn]);
  const sorted = useMemo(() => {
    return [...filtered].sort((a, b) => {
      const cmp = a[sortField].localeCompare(b[sortField]);
      return sortDirection === 'asc' ? cmp : -cmp;
    });
  }, [filtered, sortField, sortDirection]);
  // Now changing sortField doesn't re-filter, and changing filterFn doesn't re-sort
  // (Though sorted still depends on filtered, so filter changes propagate)
}

Performance Testing

Measuring Without Memoization

function SlowComponent({ items }) {
  console.time('filter');
  const expensive = items
    .filter(item => complexPredicate(item))
    .sort((a, b) => complexSort(a, b))
    .map(item => transformItem(item));
  console.timeEnd('filter');
  // If this logs > 1ms and runs frequently, consider useMemo

  return <List items={expensive} />;
}

React Profiler

import { Profiler } from 'react';

function App() {
  const onRender = (id, phase, actualDuration) => {
    console.log(`${id} ${phase}: ${actualDuration.toFixed(2)}ms`);
  };

  return (
    <Profiler id="ProductList" onRender={onRender}>
      <ProductList products={products} />
    </Profiler>
  );
}

When to Optimize

Render TimeAction
< 1msDon't optimize
1-16msConsider optimizing if renders are frequent
> 16msOptimize (causes dropped frames at 60fps)
> 100msDefinitely optimize (user-visible jank)

Profiling with React DevTools

  1. Open React DevTools -> Profiler tab
  2. Click Record
  3. Interact with your app
  4. Click Stop
  5. Look at the flame chart:
    • Gray bars = components that didn't re-render
    • Colored bars = components that re-rendered (yellow/red = slow)
  6. Click a component to see why it re-rendered:
    • "Props changed" -> check which prop
    • "Parent rendered" -> consider React.memo
    • "State changed" -> expected, check if state update is necessary

React.memo: The Missing Piece

useCallback and useMemo are only useful for passing to memoized children. React.memo is what actually prevents the child re-render.

// React.memo wraps a component and skips re-render if props are the same
const ExpensiveList = React.memo(function ExpensiveList({ items, onItemClick }) {
  console.log('ExpensiveList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onItemClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});

// Custom comparison function (optional)
const ExpensiveList = React.memo(
  function ExpensiveList({ items, onItemClick }) {
    // ...
  },
  (prevProps, nextProps) => {
    // Return true to SKIP re-render (props are "equal")
    // Return false to re-render
    return prevProps.items.length === nextProps.items.length
      && prevProps.items.every((item, i) => item.id === nextProps.items[i].id);
  }
);

The Complete Pattern

function App() {
  const [searchQuery, setSearchQuery] = useState('');
  const [selectedId, setSelectedId] = useState(null);

  // 1. Memoize the filtered list (expensive computation)
  const filteredItems = useMemo(() => {
    return allItems.filter(item =>
      item.name.toLowerCase().includes(searchQuery.toLowerCase())
    );
  }, [searchQuery]);

  // 2. Memoize the callback (stable reference for memo'd child)
  const handleSelect = useCallback((id) => {
    setSelectedId(id);
  }, []);

  return (
    <div>
      <input
        value={searchQuery}
        onChange={e => setSearchQuery(e.target.value)}
        placeholder="Search..."
      />
      {/* 3. Memoized child only re-renders when its props actually change */}
      <ItemList items={filteredItems} onSelect={handleSelect} />
      <ItemDetail id={selectedId} />
    </div>
  );
}

// 4. React.memo prevents re-render when props are unchanged
const ItemList = React.memo(function ItemList({ items, onSelect }) {
  console.log('ItemList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});
Without memoization:
  Type in search box
    -> App re-renders
    -> filteredItems recalculated
    -> handleSelect is new reference
    -> ItemList re-renders (even if items didn't change)
    -> ItemDetail re-renders

With memoization:
  Type in search box
    -> App re-renders
    -> filteredItems recalculated only if searchQuery changed
    -> handleSelect is same reference (useCallback)
    -> ItemList: React.memo checks props
       -> items changed? Maybe. If so, re-render.
       -> onSelect changed? No (useCallback). Skip if items same.
    -> ItemDetail: only re-renders if selectedId changed

useMemo for Stable Object References

Beyond performance, useMemo is useful for creating stable object references for dependency arrays.

function Map({ center, zoom }) {
  // Without useMemo: new object every render -> useEffect re-runs every render
  const options = useMemo(() => ({
    center: { lat: center.lat, lng: center.lng },
    zoom,
    mapTypeId: 'roadmap',
  }), [center.lat, center.lng, zoom]);

  useEffect(() => {
    const map = initializeMap(options);
    return () => map.destroy();
  }, [options]);  // Only re-initializes when options actually change

  return <div id="map" />;
}

Comparison Table

useMemouseCallback
ReturnsCached valueCached function
Equivalent toโ€”useMemo(() => fn, deps)
Use forExpensive computations, stable objectsStable function references
Pairs withAny consumerReact.memo children, useEffect deps
OverheadStores value + compares depsStores function + compares deps
Cache sizeLast value only (not LRU)Last function only

Key Takeaways

  1. useMemo caches values, useCallback caches functions โ€” both use dependency arrays
  2. Don't memoize by default โ€” profile first, optimize bottlenecks
  3. useCallback is only useful when paired with React.memo or as a useEffect dependency
  4. useMemo is for expensive computations (>1ms) or stable references for dependency arrays
  5. Objects and arrays in deps create new references every render โ€” destructure to primitives when possible
  6. React.memo is the gatekeeper โ€” without it on the child, useCallback on the parent is wasted
  7. Split expensive operations into multiple useMemo calls with different deps for finer cache granularity
  8. Measure before and after โ€” use React Profiler and console.time to verify your optimization helps
  9. Memoization has overhead โ€” comparing dependencies and storing cached values isn't free
  10. The best optimization is often restructuring components so fewer things re-render, not adding memoization everywhere

Found this helpful?

Support devsofus โ€” help us keep creating free dev guides.

Related Articles