useMemo & useCallback Deep Dive

Difficulty: Intermediate

Question

When should you use useMemo and useCallback? What are the costs and when are they actually necessary?

Answer

useMemo and useCallback are two hooks that get thrown around a lot in React discussions, and honestly, they're some of the most misused tools in the React developer's toolkit. Let me explain what they actually do, when they genuinely help, and when using them is actively counterproductive.

Let's start with the core concept: referential equality. In JavaScript, every time you create an object, array, or function, it gets a brand new reference in memory. Even if the content is identical. So { color: 'blue' } === { color: 'blue' } is false, and (() => 42) === (() => 42) is also false. They look the same, but they're different objects in memory.

Why does this matter in React? Because React components re-render every time their parent re-renders. And on each render, the component function runs again from top to bottom. Any objects or functions declared in the function body are created fresh. If you pass one of these freshly-created values as a prop to a child component, the child sees a 'new' prop even though the value looks the same. And if that child is wrapped in React.memo, the memo's shallow comparison sees the different reference and re-renders the child anyway, defeating the purpose of memoization.

useMemo caches a computed VALUE. You give it a function that computes something and an array of dependencies. The first time, it runs the function and caches the result. On subsequent renders, if the dependencies haven't changed, it returns the cached result without running the function again. When a dependency changes, it recomputes and caches the new result.

useCallback caches a FUNCTION reference. It's actually syntactic sugar: useCallback(fn, deps) is identical to useMemo(() => fn, deps). Instead of caching the result of calling the function, it caches the function itself, ensuring it has the same reference across renders as long as the dependencies don't change.

Now, when are these actually necessary? There are three main scenarios.

Scenario 1: Expensive computations. If your component filters and sorts an array of 50,000 products on every render, and the filter criteria haven't changed, that's wasted work. Wrapping the filter/sort in useMemo means it only recomputes when the products or filter criteria change. On subsequent renders (caused by unrelated state changes), it returns the cached result instantly. This can take an operation from 50ms to 0ms on those renders. That's a real, measurable improvement.

But here's the thing: most computations are not expensive. Summing an array of 20 numbers? That takes microseconds. String concatenation? Nanoseconds. Don't wrap these in useMemo. The overhead of storing the cache, checking dependencies, and the additional code complexity costs more than just recomputing the value. A good rule of thumb: if the computation takes less than 1 millisecond, don't bother memoizing it. You can check this by adding console.time('computation') around it.

Scenario 2: Stabilizing props for memoized children. If you have a child component wrapped in React.memo, and you pass it an object or function prop, you need useMemo/useCallback to prevent the memo from being defeated. Without them, every parent render creates a new reference, React.memo sees a 'changed' prop, and re-renders the child. With them, the reference stays stable, and React.memo correctly skips the re-render.

But if the child ISN'T wrapped in React.memo, using useCallback on the function you pass to it is pointless. The child will re-render anyway (because its parent re-rendered), regardless of whether the function reference changed. You stabilized a reference that nobody checks. Cost without benefit.

Scenario 3: Values used as hook dependencies. If you compute a value and use it in a useEffect dependency array, creating a new reference on each render will cause the effect to re-run on every render. useMemo ensures the reference stays stable, so the effect only runs when the actual value changes. This prevents infinite loops where an effect runs, causes a re-render, the re-render creates a new reference, which triggers the effect again, and so on.

Here's what over-memoization looks like in practice: wrapping every single variable in useMemo, wrapping every single callback in useCallback, even when no child component is memoized and no hook depends on them. I've seen codebases where 70% of the lines are useMemo and useCallback wrappers. The code is harder to read, harder to maintain, uses more memory (every memoized value is stored in a closure), and runs marginally slower (dependency comparison on every render). The developers thought they were being performance-conscious, but they were actually making things worse.

The practical decision framework is simple. Ask yourself three questions: 1. Is this computation genuinely expensive (takes more than 1ms)? If yes, useMemo. 2. Is this value passed as a prop to a React.memo child? If yes, useMemo or useCallback. 3. Is this value used as a dependency in useEffect, useMemo, or useCallback? If yes, consider stabilizing it.

If the answer to all three is no, just let React recompute it on every render. It's fine. React is fast.

One more important nuance: React does not guarantee that memoized values are retained forever. Under memory pressure, React may choose to evict cached values and recompute them on the next render. So you should never rely on useMemo for correctness, only for performance. Your code should work identically if React threw away every cached value and recomputed everything. If it doesn't, you have a bug.

Looking ahead, the React Compiler (formerly React Forget) is designed to automatically insert useMemo and useCallback at compile time where they provide a benefit. When it becomes mainstream, most of this manual memoization work will be handled for you. The compiler analyzes the data flow in your components and determines which values need stable references. This is the future the React team is building toward.

For interviews, the single most important thing to communicate is balanced judgment. Show that you know when memoization helps (expensive computations, memoized children, hook dependencies) AND when it hurts (cheap components, unstable other props, added complexity). Saying 'I always add useMemo to be safe' is a red flag. Saying 'I profile first, then memoize the hot paths' is what interviewers want to hear.

Code examples

When useMemo is Actually Needed

function ProductCatalog({ products, filters, sortBy }) {
  // NEEDED: Expensive filtering and sorting of large dataset
  const filteredProducts = useMemo(() => {
    console.log('Filtering', products.length, 'products');
    return products
      .filter(p => {
        if (filters.category && p.category !== filters.category) return false;
        if (filters.minPrice && p.price < filters.minPrice) return false;
        if (filters.maxPrice && p.price > filters.maxPrice) return false;
        if (filters.inStock && !p.inStock) return false;
        return true;
      })
      .sort((a, b) => {
        if (sortBy === 'price-asc') return a.price - b.price;
        if (sortBy === 'price-desc') return b.price - a.price;
        if (sortBy === 'name') return a.name.localeCompare(b.name);
        return 0;
      });
  }, [products, filters, sortBy]);

  // NOT NEEDED: Simple derivation, runs fast
  // const totalPrice = useMemo(
  //   () => filteredProducts.reduce((sum, p) => sum + p.price, 0),
  //   [filteredProducts]
  // );
  // Just compute it directly:
  const totalPrice = filteredProducts.reduce((sum, p) => sum + p.price, 0);

  return (
    <div>
      <p>Total: ${totalPrice} ({filteredProducts.length} items)</p>
      {filteredProducts.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

Filtering and sorting thousands of items on every render is expensive - useMemo avoids recomputation. A simple reduce over the already-filtered list is cheap and doesn't need memoization.

When useCallback is Actually Needed

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

// Memoized child - useCallback on parent matters
const SearchInput = memo(function SearchInput({ onSearch, onClear }) {
  console.log('SearchInput rendered');
  return (
    <div>
      <input onChange={e => onSearch(e.target.value)} />
      <button onClick={onClear}>Clear</button>
    </div>
  );
});

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [activeTab, setActiveTab] = useState('all');

  // NEEDED: SearchInput is memoized and receives this callback
  const handleSearch = useCallback((value) => {
    setQuery(value);
  }, []);

  const handleClear = useCallback(() => {
    setQuery('');
    setResults([]);
  }, []);

  // NOT NEEDED: This handler is only used in this component
  // No memoized child receives it
  function handleTabChange(tab) {
    setActiveTab(tab);
  }

  // Fetch when query changes
  useEffect(() => {
    if (!query) return;
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(setResults);
  }, [query]);

  return (
    <div>
      <SearchInput onSearch={handleSearch} onClear={handleClear} />
      <div>
        <button onClick={() => handleTabChange('all')}>All</button>
        <button onClick={() => handleTabChange('recent')}>Recent</button>
      </div>
      <ResultsList results={results} />
    </div>
  );
}

useCallback matters for handleSearch and handleClear because they are passed to a memoized child (SearchInput). handleTabChange is only used locally, so wrapping it in useCallback adds cost with no benefit.

The Cost of Unnecessary Memoization

// OVER-MEMOIZED: Every single thing is wrapped
function OverOptimized({ items }) {
  // Unnecessary: simple string concatenation
  const title = useMemo(() => `Items (${items.length})`, [items.length]);

  // Unnecessary: simple callback, no memoized children use it
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  // Unnecessary: cheap computation
  const isEmpty = useMemo(() => items.length === 0, [items.length]);

  return (
    <div>
      <h1>{title}</h1>
      {isEmpty ? <p>No items</p> : <p>Has items</p>}
      <button onClick={handleClick}>Log</button>
    </div>
  );
}

// CORRECTLY OPTIMIZED: Only memoize what matters
function Optimized({ items }) {
  const title = `Items (${items.length})`;
  const isEmpty = items.length === 0;

  return (
    <div>
      <h1>{title}</h1>
      {isEmpty ? <p>No items</p> : <p>Has items</p>}
      <button onClick={() => console.log('clicked')}>Log</button>
    </div>
  );
}

// Rules of thumb:
// 1. Is the computation expensive? (> 1ms) -> useMemo
// 2. Is it passed to a React.memo child? -> useMemo/useCallback
// 3. Is it used as a hook dependency? -> useMemo/useCallback
// 4. Otherwise -> don't memoize

Unnecessary memoization adds memory overhead, comparison costs on every render, and code complexity. Only memoize when there is a measurable performance benefit.

Key points

Concepts covered

useMemo, useCallback, Memoization, Referential Equality