Skip to main content

⚑ Performance Optimization with Memoization

React is fast by default β€” until a busy component tree starts re-rendering more than it needs to. Memoization is the toolkit for skipping work that would produce the same result: React.memo for components, useMemo for values, useCallback for functions. Used well, they turn a sluggish grid snappy. Used everywhere, they just add overhead.

🎯 Learning Objectives

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

  • Describe how and when React re-renders a component and its children
  • Explain memoization and the role of referential equality
  • Apply React.memo, useMemo, and useCallback correctly
  • Recognize when memoization helps and when it just adds cost
  • Measure render performance with the React DevTools Profiler

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Optimize a shopping-cart component with all three tools.

In This Lesson

How React Re-renders

To optimize rendering you first have to understand it. React re-renders in three situations:

  1. A component's own state changes.
  2. A component's props change.
  3. Its parent re-renders β€” which, by default, re-renders all of its children, whether or not their props changed.
πŸ‘¨β€πŸ‘©β€πŸ‘§ A useful analogy: React's cascade is like a family getting ready in the morning. If a parent decides to change outfits (state change), every child at least has to check whether they need to change too β€” even the ones who end up wearing the same thing.
Default re-render cascade A parent whose state changes re-renders, and by default all of its descendant components re-render as well. Parent state changes Child 1 Β· re-renders Child 2 Β· re-renders Child 3 Β· re-renders Grandchild Β· re-renders
Figure 1 β€” By default, one parent state change re-renders the entire subtree beneath it, even children whose props are unchanged.

For small apps this is completely fine β€” a re-render is cheap, and React only touches the real DOM where output actually differs. The trouble appears in deep trees or lists where an expensive child re-renders on every unrelated parent update.

What Is Memoization?

Memoization is a general programming technique: cache the result of an expensive computation and return the cached value when the same inputs come back. It's remembering the answer instead of recomputing it.

β˜• Real-world analogy: A barista who keeps a few popular drinks pre-made serves them instantly (cached). Only when someone orders something different β€” or the pre-made batch goes stale β€” do they brew fresh. In React, the "same order" is "the same inputs," and "stale" is "a dependency changed."

In React, memoization does three jobs:

  • Skip re-rendering a component whose props didn't change (React.memo).
  • Skip recomputing an expensive value (useMemo).
  • Keep a function or object referentially stable across renders (useMemo / useCallback).

πŸ“– Referential equality β€” the concept behind it all

In JavaScript, {} === {} is false and (() => {}) === (() => {}) is false. Two objects/functions are "equal" only if they're the same reference. React's memoization compares props by reference, so a freshly created object or function counts as "changed" even when its contents are identical. Half of memoization is about keeping references stable.

React.memo for Components

React.memo wraps a component so React skips re-rendering it when its props haven't changed (by a shallow comparison). It's the tool for breaking the default cascade.

import { memo, useState } from 'react';

function ProfileCard({ user }) {
  console.log('ProfileCard rendered');
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// Memoized: re-renders only when the `user` prop reference changes
const MemoProfileCard = memo(ProfileCard);

function Dashboard() {
  const [user] = useState({ name: 'Jane', email: 'jane@example.com' });
  const [count, setCount] = useState(0);

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

      <ProfileCard user={user} />      {/* re-renders on every click */}
      <MemoProfileCard user={user} />  {/* skips β€” user is unchanged */}
    </div>
  );
}

Clicking the button changes count, re-rendering Dashboard. The plain ProfileCard re-renders needlessly; MemoProfileCard is skipped because its user reference is stable.

flowchart TD A["Dashboard: count changes"] --> B["ProfileCard: re-renders (wasteful)"] A --> C["MemoProfileCard: skipped βœ“"]

Custom comparison

By default memo shallow-compares props. For complex props you can supply your own comparator. It returns true when props are equal (skip render) β€” the opposite sense of the old shouldComponentUpdate:

const Row = memo(RowComponent, (prev, next) => {
  // return true => treat as equal => skip re-render
  return prev.id === next.id && prev.label === next.label;
});

The Referential-Equality Trap

Here's the gotcha that trips everyone up: React.memo alone often does nothing, because objects and functions are recreated on every render.

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

  const user = { name: 'Jane', age: 30 };      // πŸ”΄ new object each render
  const handleClick = () => console.log('hi');  // πŸ”΄ new function each render

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>

      {/* Re-renders every time despite memo β€” props are new references */}
      <MemoChild user={user} onClick={handleClick} />
    </div>
  );
}

Every click recreates user and handleClick. Because MemoChild compares by reference, it sees "new props" and re-renders anyway. React.memo is only as good as the stability of the props you feed it β€” which is exactly why useMemo and useCallback exist.

useMemo for Values

useMemo caches the result of a computation, recomputing only when its dependencies change. Use it for genuinely expensive calculations and to keep object/array props referentially stable.

import { useMemo } from 'react';

function Report({ rows, filter }) {
  // Recomputes only when rows or filter change β€” not on every render
  const summary = useMemo(() => {
    console.log('crunching numbers…');
    return computeExpensiveSummary(rows, filter);
  }, [rows, filter]);

  return <SummaryView data={summary} />;
}

And to fix the referential trap from the previous section β€” memoize the object so its reference stays stable:

function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Jane');

  // Same reference until `name` changes
  const user = useMemo(() => ({ name, age: 30 }), [name]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <MemoChild user={user} />  {/* now genuinely skips on count changes */}
    </div>
  );
}

πŸ’‘ Use useMemo when…

  • a calculation is measurably expensive (sorting/filtering large lists, parsing);
  • you pass an object or array as a prop to a memoized child;
  • a value is a dependency of another hook and must stay stable.

useCallback for Functions

useCallback is useMemo for functions: it returns the same function reference between renders until its dependencies change. Its main job is keeping callbacks stable when you pass them to memoized children.

import { useCallback } from 'react';

function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Jane');

  // Stable reference until `name` changes
  const handleGreet = useCallback(() => {
    console.log(`Hi from ${name}`);
  }, [name]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <MemoButton onClick={handleGreet} label="Greet" />
    </div>
  );
}

βœ… Remember the equivalence

useCallback(fn, deps) is exactly useMemo(() => fn, deps). Reach for useCallback when the memoized value is a function β€” event handlers passed to React.memo children, or functions used as effect/hook dependencies.

When (and When Not) to Memoize

Memoization isn't free β€” every memo/useMemo/useCallback adds a comparison and stores a cached value in memory. Applied blindly, it can make code slower and harder to read.

βœ… Do memoize

  • Components that render often with the same props.
  • Components doing genuinely expensive work.
  • Expensive components deep in the tree re-triggered by parent state.
  • Objects/functions passed as props to memoized children.

❌ Don't memoize

  • Components that always receive different props anyway.
  • Cheap components that render in microseconds.
  • Trivial values β€” const sum = a + b; never needs useMemo.
  • Everything "just in case" β€” the overhead outweighs the gain.
⚠️ "Premature optimization is the root of all evil." Start without memoization. Measure. Add it only where the profiler shows a real, repeated cost.

πŸ€– The React Compiler changes the calculus

The React Compiler (introduced with React 19) can automatically memoize components and values at build time β€” potentially making most manual useMemo/useCallback unnecessary in projects that adopt it. Understanding the manual tools still matters: it's how you reason about re-renders, read existing code, and optimize projects that haven't enabled the compiler.

Measuring Performance

Never optimize by guesswork. Measure first, so you fix a real bottleneck instead of a suspected one.

React DevTools Profiler

The Profiler tab in React DevTools is the primary tool:

  • Record an interaction and see which components rendered.
  • Read why each component rendered (props, state, parent).
  • Compare render durations before and after a change.

Other tools

ToolUse
why-did-you-renderLogs avoidable re-renders and the prop that caused them
LighthouseOverall page performance and Core Web Vitals
<Profiler> APIProgrammatic render timing in code

Hands-on: Optimize a Cart

πŸ‹οΈ Speed up a shopping cart

Objective: Apply all three tools to a cart where typing a coupon code shouldn't re-render every line item.

Starting point (unoptimized):

function ShoppingCart() {
  const [items, setItems] = useState([
    { id: 1, name: 'Laptop', price: 999, quantity: 1 },
    { id: 2, name: 'Headphones', price: 99, quantity: 1 },
    { id: 3, name: 'Keyboard', price: 59, quantity: 1 },
  ]);
  const [coupon, setCoupon] = useState('');

  const total = items.reduce((s, i) => s + i.price * i.quantity, 0);
  const discount = coupon === 'SAVE10' ? total * 0.1 : 0;

  const updateQuantity = (id, qty) =>
    setItems(items.map(i => (i.id === id ? { ...i, quantity: qty } : i)));
  const removeItem = (id) => setItems(items.filter(i => i.id !== id));
  // ...renders CartItem for each item + a coupon input
}

Your tasks:

  1. Wrap CartItem in React.memo.
  2. Memoize total, discount, and finalTotal with useMemo.
  3. Stabilize updateQuantity and removeItem with useCallback.
  4. Pick correct dependency arrays for each.
πŸ’‘ Hint

Use the functional updater form (setItems(prev => …)) inside your callbacks so they don't depend on items β€” that lets their dependency arrays be empty and their references stay stable forever. total depends on items; discount depends on coupon and total.

βœ… Sample solution
import { memo, useCallback, useMemo, useState } from 'react';

const CartItem = memo(function CartItem({ item, updateQuantity, removeItem }) {
  return (
    <div className="cart-item">
      <h3>{item.name}</h3>
      <p>${item.price}</p>
      <input
        type="number" min="1" value={item.quantity}
        onChange={(e) => updateQuantity(item.id, Number(e.target.value))}
      />
      <p>Subtotal: ${(item.price * item.quantity).toFixed(2)}</p>
      <button onClick={() => removeItem(item.id)}>Remove</button>
    </div>
  );
});

function ShoppingCart() {
  const [items, setItems] = useState([
    { id: 1, name: 'Laptop', price: 999, quantity: 1 },
    { id: 2, name: 'Headphones', price: 99, quantity: 1 },
    { id: 3, name: 'Keyboard', price: 59, quantity: 1 },
  ]);
  const [coupon, setCoupon] = useState('');

  const total = useMemo(
    () => items.reduce((s, i) => s + i.price * i.quantity, 0),
    [items]
  );
  const discount = useMemo(
    () => (coupon === 'SAVE10' ? total * 0.1 : 0),
    [coupon, total]
  );
  const finalTotal = useMemo(() => total - discount, [total, discount]);

  // Empty deps: functional updaters mean these never need to change
  const updateQuantity = useCallback((id, qty) => {
    setItems(prev => prev.map(i => (i.id === id ? { ...i, quantity: qty } : i)));
  }, []);
  const removeItem = useCallback((id) => {
    setItems(prev => prev.filter(i => i.id !== id));
  }, []);

  return (
    <div className="shopping-cart">
      <h2>Your Cart</h2>
      {items.map(item => (
        <CartItem key={item.id} item={item}
                  updateQuantity={updateQuantity} removeItem={removeItem} />
      ))}
      <input value={coupon} onChange={(e) => setCoupon(e.target.value)}
             placeholder="Coupon code" />
      <p>Subtotal: ${total.toFixed(2)}</p>
      {discount > 0 && <p>Discount: -${discount.toFixed(2)}</p>}
      <p>Total: ${finalTotal.toFixed(2)}</p>
    </div>
  );
}

Now typing in the coupon field re-renders only the cart shell β€” each memoized CartItem receives stable props and skips. Verify it in the Profiler!

🎯 Quick Quiz

Question 1: By default, what happens to a component's children when the component re-renders?

Question 2: Why might a React.memo child still re-render every time?

Question 3: Which is the right tool to keep an event handler's reference stable across renders?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • By default, a parent re-render cascades to all its children.
  • Memoization skips work that would produce the same result, hinging on referential equality.
  • React.memo skips a component; useMemo caches a value; useCallback stabilizes a function.
  • React.memo only helps if its props are referentially stable β€” that's why useMemo/useCallback pair with it.
  • Measure first, memoize where it pays off, and expect the React Compiler to automate much of this going forward.

πŸ“š Further Reading

πŸš€ What's Next?

Local component state and memoization take you a long way, but large apps need shared, predictable state. Next we'll step into Redux Core Concepts β€” actions, reducers, and a single global store.

πŸŽ‰ Nicely optimized!

You can now find and fix wasteful renders. Let's scale state management up.