Skip to main content

โšก React.memo and useMemo

A React app that works and a React app that feels fast are two different things. In this lesson you'll learn why React re-renders, when that re-rendering actually costs you, and how the two headline memoization tools โ€” React.memo and useMemo โ€” let you skip the work that doesn't need doing. Just as important: how to tell when not to reach for them.

Week 5 · Day 4 (Thursday: Performance Optimization) · Lecture 1

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what triggers a React re-render and why re-renders cascade to children
  • Wrap a component in React.memo to skip re-renders when its props are unchanged
  • Cache expensive calculations with useMemo and write correct dependency arrays
  • Reason about referential equality โ€” why a fresh object or array defeats memoization
  • Combine React.memo and useMemo to keep a data-heavy dashboard responsive
  • Decide when memoizing is worth it, and recognize over-optimization when you see it

Estimated Time: 70 minutes

Practice: Profile a laggy list, then fix it with React.memo and a memoized filter.

In This Lesson

Why Re-renders Happen

Picture a busy restaurant kitchen. A good chef doesn't chop the same onion from scratch every single time a plate goes out โ€” they prep ingredients once and reuse them. React memoization is the same instinct applied to your UI: remember the result of work you've already done, and reuse it when nothing relevant has changed.

Before you can optimize re-renders, you need to know what causes them. A React function component re-runs (re-renders) when any of these happen:

  • Its own state changes (a useState setter fires)
  • A context value it reads changes
  • Its parent re-renders โ€” even if this component's props are identical

That last one surprises people. By default, when a parent re-renders, React re-renders all of its children, all the way down. Most of the time that's completely fine โ€” rendering is usually cheap. The problem is the exceptions: a child that does heavy work, or a subtree with hundreds of nodes, re-running on every unrelated keystroke.

graph TD A[State changes in Parent] --> B[Parent re-renders] B --> C[Child A re-renders] B --> D[Child B re-renders] B --> E[Child C re-renders] C --> F[Grandchildren re-render...] D --> F E --> F F --> G[Expensive work repeated
even if props never changed]

Memoization gives you two scissors to snip that cascade. React.memo stops the re-render from reaching a component when its props are unchanged. useMemo stops an expensive calculation from re-running inside a component that does re-render. Let's take them one at a time.

๐Ÿ“– "Re-render" is not "repaint"

A re-render means React re-runs your component function and produces a new virtual DOM description. It then diffs that against the previous one and only touches the real DOM where something actually differs. Re-rendering is cheaper than you think โ€” which is exactly why you should measure before assuming a re-render is your bottleneck.

React.memo: Skipping Re-renders

React.memo is a higher-order component. You wrap a component in it, and React will skip re-rendering that component if its props are the same as last time (compared shallowly). Think of it as a bouncer at the component's door: "Same props as before? You're not coming in โ€” reuse the previous render."

Basic usage

import { memo, useState } from 'react';

// WITHOUT memo โ€” re-renders on every parent update, even when `data` is unchanged
function PlainList({ data }) {
  console.log('PlainList rendered');
  const doubled = data.map(n => n * 2);
  return (
    <ul>
      {doubled.map((n, i) => <li key={i}>{n}</li>)}
    </ul>
  );
}

// WITH memo โ€” only re-renders when the `data` prop actually changes
const MemoList = memo(function MemoList({ data }) {
  console.log('MemoList rendered');
  const doubled = data.map(n => n * 2);
  return (
    <ul>
      {doubled.map((n, i) => <li key={i}>{n}</li>)}
    </ul>
  );
});

function Parent() {
  const [count, setCount] = useState(0);
  // `data` is created once and kept stable across renders
  const [data] = useState([1, 2, 3, 4, 5]);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Clicked {count} times
      </button>

      {/* Logs on EVERY click โ€” count changed, so Parent re-renders it */}
      <PlainList data={data} />

      {/* Logs only ONCE โ€” `data` never changes, so memo skips it */}
      <MemoList data={data} />
    </div>
  );
}

Click the button and watch the console. PlainList logs every click; MemoList logs once and then stays quiet, because data is referentially the same array each time. That last phrase โ€” "referentially the same" โ€” is the whole game, and we'll return to it in its own section.

๐Ÿ’ก Note on React.memo vs memo

Modern React lets you import memo directly: import { memo } from 'react'. You'll also see React.memo(...) in code that imports the default React object โ€” they're the same function. We'll use the named import going forward.

How the comparison works

By default memo does a shallow comparison of props: for each prop, it checks Object.is(prevProp, nextProp). Primitives (numbers, strings, booleans) compare by value, so they "just work." Objects, arrays, and functions compare by reference โ€” two identical-looking objects are still different if they're two separate objects in memory.

React.memo compares previous and next props; equal props skip the render, different props allow it prev props {'{ data }'} next props {'{ data }'} Object.is equal? YES โ†’ skip render reuse previous UI NO โ†’ re-render run the component
memo shallow-compares each prop. Equal props short-circuit the render; any difference lets the render through.

A custom comparison function

You can pass a second argument to memo that decides equality yourself. It returns true to skip the render (props considered equal) and false to re-render. This is the opposite polarity of an array's sort comparator, so read it carefully.

const UserCard = memo(
  function UserCard({ user }) {
    console.log('UserCard rendered for:', user.name);
    return (
      <div className="user-card">
        <h3>{user.name}</h3>
        <p>Email: {user.email}</p>
        <p>Last active: {user.lastActive}</p>
      </div>
    );
  },
  // Return true = "equal, skip". Only re-render when name or email changes;
  // deliberately ignore lastActive so a heartbeat update doesn't re-render.
  (prev, next) =>
    prev.user.name === next.user.name &&
    prev.user.email === next.user.email
);

โš ๏ธ Custom comparators are a sharp tool

A hand-written comparator is easy to get subtly wrong โ€” forget a field and the UI silently goes stale. Reserve it for real, measured cases (like ignoring a noisy field). Most of the time, the default shallow comparison plus stable props is the better answer.

When React.memo earns its keep

  • The component is expensive to render (large list, chart, complex tree).
  • It re-renders often because its parent updates frequently for unrelated reasons.
  • It's usually given the same props across those re-renders.

If a component is trivial โ€” a button, a label โ€” wrapping it in memo can cost more (the comparison itself) than it saves. Don't sprinkle it everywhere.

useMemo: Caching Calculations

Where React.memo guards a whole component, useMemo guards a single value computed inside a component. It runs a function and remembers its result, only recomputing when one of its dependencies changes. It's a cache with a very precise invalidation rule.

Basic usage

import { useMemo } from 'react';

// WITHOUT useMemo โ€” filter runs on EVERY render, even unrelated ones
function FilteredList({ items, query }) {
  console.log('Filtering (every render)...');
  const filtered = items.filter(item =>
    item.name.toLowerCase().includes(query.toLowerCase())
  );
  return <List items={filtered} />;
}

// WITH useMemo โ€” filter only re-runs when items OR query changes
function OptimizedFilteredList({ items, query }) {
  const filtered = useMemo(() => {
    console.log('Filtering (only when deps change)...');
    return items.filter(item =>
      item.name.toLowerCase().includes(query.toLowerCase())
    );
  }, [items, query]); // โ† dependency array

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

The dependency array is the contract: "recompute only if one of these changed." Get it right and the cache is correct and fast. Get it wrong โ€” leave out a value the calculation reads โ€” and you'll serve a stale result.

โœ… The ESLint rule that saves you

Install eslint-plugin-react-hooks and enable react-hooks/exhaustive-deps. It flags missing dependencies in useMemo, useCallback, and useEffect โ€” the single most common source of memoization bugs. Treat its warnings as errors until you understand exactly why you're overriding one.

Genuinely expensive work

useMemo's sweet spot is a calculation that's actually costly โ€” statistics over a large array, parsing, building derived data structures. Here everything descends from data, so we only pay when data changes:

function DataAnalytics({ data, thresholds }) {
  // Expensive: statistics over a potentially huge array
  const stats = useMemo(() => {
    console.log('Calculating statistics...');
    const mean = data.reduce((sum, v) => sum + v, 0) / data.length;
    const variance =
      data.reduce((sum, v) => sum + (v - mean) ** 2, 0) / data.length;
    return { mean, stdDev: Math.sqrt(variance) };
  }, [data]);

  // Depends on `stats` AND `thresholds` โ€” recompute if either changes
  const rows = useMemo(() => {
    console.log('Transforming rows...');
    return data.map(value => ({
      value,
      zScore: (value - stats.mean) / stats.stdDev,
      band: value > thresholds.high ? 'high'
          : value < thresholds.low ? 'low' : 'mid',
    }));
  }, [data, stats, thresholds]);

  return (
    <div>
      <p>Mean {stats.mean.toFixed(2)} ยท Std dev {stats.stdDev.toFixed(2)}</p>
      <DataTable rows={rows} />
    </div>
  );
}

Memoizing objects and arrays for stable references

useMemo has a second job beyond saving CPU: producing a stable reference for an object or array you pass to a memoized child or a hook dependency. Even if building the object is cheap, giving the child the same object each render is what lets React.memo do its work.

function SearchPanel({ defaultFilters }) {
  const [term, setTerm] = useState('');
  const [order, setOrder] = useState('asc');

  // โŒ A brand-new object every render โ€” any memoized child sees "new props"
  const badConfig = { term, order, ...defaultFilters };

  // โœ… Same object identity until term/order/defaultFilters actually change
  const config = useMemo(
    () => ({ term, order, ...defaultFilters }),
    [term, order, defaultFilters]
  );

  // This list never changes โ€” build it once with an empty dep array
  const sortOptions = useMemo(() => [
    { value: 'asc',  label: 'Ascending' },
    { value: 'desc', label: 'Descending' },
  ], []);

  return (
    <div>
      <SortSelector options={sortOptions} value={order} onChange={setOrder} />
      <FilteredResults config={config} />
    </div>
  );
}

This "stable reference" role is the bridge to the next section โ€” and to the next lesson, where useCallback does exactly this for functions.

Referential Equality: The Idea That Ties It Together

Here's the trap that catches everyone. You wrap a child in React.memo, expecting it to stop re-rendering โ€” but it re-renders anyway. Why? Because you're handing it a fresh object, array, or function on every render, and those compare by reference.

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

  // ๐Ÿ”ด A new object literal every single render
  const config = { color: 'blue', size: 'large' };

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      {/* MemoChild re-renders every click โ€” `config` is never "equal" */}
      <MemoChild config={config} />
    </div>
  );
}

Every time Parent renders, { color: 'blue', size: 'large' } creates a new object. To React's shallow comparison, prevConfig !== nextConfig, so the memoized child re-renders. The fix is to make the reference stable with useMemo:

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

  // โœ… Built once, reused every render โ€” reference stays equal
  const config = useMemo(() => ({ color: 'blue', size: 'large' }), []);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <MemoChild config={config} />   {/* now truly skips re-renders */}
    </div>
  );
}

๐Ÿ’ก The mental model

React.memo is only as good as the stability of the props you feed it. Memoizing a child is a two-part contract: wrap the child and keep its object/array/function props referentially stable. Miss the second half and the first half does nothing.

The same reasoning explains why passing an inline arrow function โ€” onClick={() => doThing()} โ€” to a memoized child defeats the memo: a new function is created each render. That specific problem is what useCallback exists to solve, and it's the whole topic of the next lesson.

Combining React.memo and useMemo

Real optimization usually layers them. The parent uses useMemo to compute derived data once and to keep props stable; the children are wrapped in React.memo so they only re-render when their specific slice of data changes.

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

function DataDashboard({ rawData, filters }) {
  const [sortConfig, setSortConfig] = useState({ key: 'name', order: 'asc' });

  // Expensive pipeline โ€” recompute only when its real inputs change
  const processedData = useMemo(() => {
    console.log('Processing data...');
    return rawData
      .filter(item => applyFilters(item, filters))
      .map(enrichData)
      .sort((a, b) => sortData(a, b, sortConfig));
  }, [rawData, filters, sortConfig]);

  // Summary stats derive from the processed data
  const stats = useMemo(() => ({
    total: processedData.length,
    average: processedData.reduce((s, x) => s + x.value, 0) /
             (processedData.length || 1),
  }), [processedData]);

  return (
    <div>
      <Statistics stats={stats} />
      <DataTable data={processedData} sortConfig={sortConfig} />
    </div>
  );
}

// Child re-renders only when `stats` changes โ€” not on every parent render
const Statistics = memo(function Statistics({ stats }) {
  console.log('Statistics rendered');
  return (
    <div className="statistics">
      <div>Total: {stats.total}</div>
      <div>Average: {stats.average.toFixed(2)}</div>
    </div>
  );
});

const DataTable = memo(function DataTable({ data, sortConfig }) {
  console.log('DataTable rendered');
  return (
    <table>
      <tbody>
        {data.map(item => (
          <tr key={item.id}><td>{item.name}</td><td>{item.value.toFixed(2)}</td></tr>
        ))}
      </tbody>
    </table>
  );
});

Notice the shape: useMemo both saves the expensive processing and hands each memoized child a stable prop. The two hooks aren't rivals โ€” they're partners covering different halves of the same problem.

๐Ÿ“– A word on the React Compiler

Newer React tooling (the React Compiler) can insert a lot of this memoization for you automatically, so hand-written memo/useMemo may shrink over time. It's still essential to understand the underlying model: the compiler optimizes the same re-render and referential-equality mechanics you're learning here, and you'll still read and maintain plenty of code that memoizes by hand.

Measure First: Don't Optimize Blind

The cardinal rule of performance work: measure before you optimize. Memoization adds code, comparison cost, and memory. If a component isn't actually slow, adding memo and useMemo makes your code harder to read for zero benefit โ€” sometimes for negative benefit.

React DevTools Profiler

The React DevTools browser extension has a Profiler tab. Record an interaction, and it shows you a flame graph of what rendered, how long each component took, and โ€” crucially โ€” why each one rendered. Start there. Optimize the components that actually show up hot.

A tiny "why did this render?" hook

For quick console-level insight, this hook logs which props changed between renders โ€” a fast way to catch a referential-equality leak:

import { useRef, useEffect } from 'react';

function useWhyDidYouUpdate(name, props) {
  const previous = useRef();
  useEffect(() => {
    if (previous.current) {
      const changed = {};
      for (const key of Object.keys({ ...previous.current, ...props })) {
        if (previous.current[key] !== props[key]) {
          changed[key] = { from: previous.current[key], to: props[key] };
        }
      }
      if (Object.keys(changed).length) {
        console.log('[why-did-you-update]', name, changed);
      }
    }
    previous.current = props;
  });
}

// Usage: drop it at the top of a component you suspect is re-rendering too much
// useWhyDidYouUpdate('DataTable', { data, sortConfig });

If it reports that config or onClick "changed" on every render even though they look identical โ€” congratulations, you've found a referential-equality problem, and now you know exactly which prop to stabilize.

Practice & Quiz

๐Ÿ‹๏ธ Exercise 1: Stop the leak

Goal: This memoized child re-renders on every click even though settings looks unchanged. Find out why and fix it โ€” the child should render only once.

const Panel = memo(function Panel({ settings }) {
  console.log('Panel rendered');
  return <div>{settings.mode}</div>;
});

function App() {
  const [count, setCount] = useState(0);
  const settings = { mode: 'dark' };   // ๐Ÿž something's off here
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <Panel settings={settings} />
    </div>
  );
}
๐Ÿ’ก Hint

settings is a fresh object literal on every render, so React.memo's shallow comparison always sees a new reference. Give it a stable identity.

โœ… Solution
function App() {
  const [count, setCount] = useState(0);
  // Stable reference โ€” built once, reused every render
  const settings = useMemo(() => ({ mode: 'dark' }), []);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <Panel settings={settings} />
    </div>
  );
}
// Panel now logs exactly once.

๐Ÿ‹๏ธ Exercise 2: Memoize an expensive derived value

Goal: A component filters a large list on every render, including when an unrelated theme state toggles. Wrap the filter in useMemo with a correct dependency array.

โœ… Solution
function ProductList({ products, query }) {
  const [theme, setTheme] = useState('light'); // unrelated to filtering

  const visible = useMemo(() => {
    return products.filter(p =>
      p.name.toLowerCase().includes(query.toLowerCase())
    );
  }, [products, query]); // theme is NOT a dependency โ€” filtering ignores it

  return (
    <div className={theme}>
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        Toggle theme
      </button>
      <ul>{visible.map(p => <li key={p.id}>{p.name}</li>)}</ul>
    </div>
  );
}
// Toggling the theme no longer re-runs the filter.

๐ŸŽฏ Quick Quiz

Question 1: What does React.memo compare by default to decide whether to skip a re-render?

Question 2: You wrapped a child in React.memo but it still re-renders every time the parent updates. What's the most likely cause?

Question 3: When is useMemo genuinely worth adding?

Best Practices & Pitfalls

โœ… Do

  • Measure first with the React DevTools Profiler โ€” optimize the components that are actually hot
  • Keep dependency arrays complete and honest; let exhaustive-deps guide you
  • Use useMemo to keep object/array props referentially stable for memoized children
  • Reach for React.memo on components that are expensive and often re-rendered with the same props
  • Move truly constant values outside the component so they never need to be dependencies

โŒ Don't

  • Wrap every component in memo โ€” the comparison isn't free, and it clutters the code
  • Memoize trivial math like count * 2; the hook costs more than the calculation
  • Leave a value out of the dependency array to "make it stop recomputing" โ€” that ships a stale bug
  • Assume a re-render is the bottleneck without profiling; rendering is often cheap
  • Write a custom comparator unless you've measured a specific need โ€” it's easy to make stale

โš ๏ธ The stale-dependency trap

// ๐Ÿ”ด WRONG: `query` is read but missing from deps โ†’ stale results
const filtered = useMemo(() => {
  return items.filter(i => i.includes(query));
}, [items]);            // should be [items, query]

// โœ… RIGHT
const filtered = useMemo(() => {
  return items.filter(i => i.includes(query));
}, [items, query]);

If a value appears inside the memo function, it belongs in the dependency array. Removing it doesn't fix a performance problem โ€” it hides a correctness one.

Summary

๐ŸŽ‰ Key Takeaways

  • A component re-renders on state, context, or parent changes โ€” parents re-render children by default
  • React.memo skips a component's re-render when its props are shallowly equal
  • useMemo caches an expensive value and recomputes only when its dependencies change
  • Referential equality is the linchpin: a fresh object/array/function prop defeats memo
  • Use useMemo to keep props stable, then let React.memo do its job
  • Measure first โ€” don't pay memoization's cost where there's no benefit

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

You've seen that inline functions break memoization because they get a new reference every render. The next lesson introduces the hook built precisely for that problem: useCallback โ€” memoizing function references so your React.memo children finally stay put.

โšก Great pace!

You can now reason about why React re-renders and cut the work that doesn't need repeating โ€” measuring before you reach for the scissors.