Chapter 16

React.memo and the Complete Re-render Control System

Why You Need a System, Not Just a Tool

A common pattern after developers discover React.memo: wrap every component in it, sit back, and expect performance to improve. It rarely does. Worse, it often makes debugging harder and introduces subtle bugs where the UI stops updating when it should.

The reason is that React.memo, useMemo, and useCallback must work together as an integrated system. They form a triangle โ€” each corner reinforces the others. Remove any one corner and the optimization collapses entirely. This chapter builds that system from the ground up, starting with implementation details and ending with the engineering patterns that actually work.

How React.memo Works Under the Hood

React.memo is a higher-order component that wraps your component in a special React fiber type. Before React calls your component function, it runs a props comparison using shallow equality. The comparison is equivalent to:

function shallowEqual(prevProps: object, nextProps: object): boolean {
  // Fast path: same reference means definitely equal
  if (Object.is(prevProps, nextProps)) return true;

  // Both must be non-null objects
  if (
    typeof prevProps !== 'object' || prevProps === null ||
    typeof nextProps !== 'object' || nextProps === null
  ) return false;

  const prevKeys = Object.keys(prevProps);
  const nextKeys = Object.keys(nextProps);

  // Different number of props โ†’ not equal
  if (prevKeys.length !== nextKeys.length) return false;

  // Each prop value compared with Object.is (not deep equality)
  for (const key of prevKeys) {
    if (
      !Object.prototype.hasOwnProperty.call(nextProps, key) ||
      !Object.is(prevProps[key as keyof typeof prevProps], nextProps[key as keyof typeof nextProps])
    ) {
      return false;
    }
  }

  return true;
}

The critical insight: each prop value is compared with Object.is, not deep equality. The consequences:

That last point โ€” functions created fresh on every render โ€” is the entire reason useCallback exists.

useCallback: Stabilizing Function References

Here's the most common React.memo failure mode:

// โŒ Looks optimized, actually does nothing
const MemoList = React.memo(function List({
  items,
  onItemClick,
}: {
  items: Array<{ id: number; name: string }>;
  onItemClick: (id: number) => void;
}) {
  console.log('List rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onItemClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});

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

  // โŒ New function reference on every Parent render
  const handleClick = (id: number) => {
    console.log('Clicked:', id);
  };

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <MemoList items={items} onItemClick={handleClick} />
    </>
  );
}

Every time count changes, Parent re-renders, handleClick is a new function object in memory, the onItemClick prop comparison fails, and React.memo does nothing to prevent the re-render. The memo comparison runs every time and costs slightly more than just rendering directly.

The fix uses useCallback to preserve the function reference across renders:

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

  // โœ… Same function reference across renders until deps change
  const handleClick = useCallback((id: number) => {
    console.log('Clicked:', id);
  }, []); // Empty deps: function body doesn't close over any changing values

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <MemoList items={items} onItemClick={handleClick} />
    </>
  );
}

useCallback(fn, deps) returns the same function reference until one of its dependencies changes. It does not make the function run faster. It only stabilizes the reference so that React.memo's comparison can do its job.

The Dependency Trap

// โŒ Wrong: reads userId from state but doesn't list it as a dependency
const handleSubmit = useCallback((orderId: string) => {
  submitOrder(userId, orderId); // userId is component state
}, []); // Stale closure! userId will always be the value from the first render.

// โœ… Correct: all closed-over values that can change must be listed
const handleSubmit = useCallback((orderId: string) => {
  submitOrder(userId, orderId);
}, [userId]); // Function rebuilds when userId changes

The eslint-plugin-react-hooks package has an exhaustive-deps rule that catches these mistakes automatically at lint time. It should be treated as an error, not a warning, in any serious codebase.

useMemo: Stabilizing Object References and Caching Computations

The same problem that affects function props applies equally to object props:

// โŒ New object reference on every render โ†’ memo comparison always fails
function Parent() {
  const [count, setCount] = useState(0);
  const config = { theme: 'dark', pageSize: 20 }; // New object every render

  return <MemoChild config={config} />;
}

// โœ… Stable reference: only changes when dependencies change
function Parent() {
  const [count, setCount] = useState(0);
  const config = useMemo(() => ({ theme: 'dark', pageSize: 20 }), []);
  // Empty deps: config never changes (it's constant data)

  return <MemoChild config={config} />;
}

useMemo(factory, deps) caches the factory's return value and only recomputes it when a dependency changes. It serves two distinct purposes:

Purpose 1: Stabilizing references for memo'd child components (shown above)

Purpose 2: Caching expensive computations

function DataTable({ rawData }: { rawData: DataRow[] }) {
  // Without useMemo: this runs on every render, even for unrelated state changes
  // const processed = rawData.filter(d => d.active).sort(compareFn);

  // With useMemo: only recomputes when rawData changes
  const processed = useMemo(() => {
    return rawData
      .filter(d => d.active)
      .sort((a, b) => b.timestamp - a.timestamp);
  }, [rawData]);

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

Real benchmark context: sorting and filtering 10,000 rows takes roughly 8-15ms depending on hardware. If the parent component re-renders 60 times per second due to an animation, that's up to 900ms of wasted computation per second. useMemo reduces this to near zero for renders where rawData hasn't changed.

The memo + useMemo + useCallback Triangle

The three tools have distinct roles that complement each other:

Tool What it stabilizes When you need it
React.memo Whether to call a component function Child component with expensive render, parent re-renders frequently
useCallback A function reference passed as a prop Passing callbacks to memo'd components
useMemo An object/array reference passed as a prop, or an expensive computed value Passing derived objects to memo'd components, or expensive derivations

A complete example that uses all three correctly:

const ProductCard = React.memo(function ProductCard({
  product,
  onAddToCart,
  onRemove,
}: {
  product: Product;
  onAddToCart: (id: string) => void;
  onRemove: (id: string) => void;
}) {
  console.log(`ProductCard rendered: ${product.id}`);
  return (
    <div className="card">
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
      <button onClick={() => onRemove(product.id)}>Remove</button>
    </div>
  );
});

function Catalog({ rawProducts }: { rawProducts: RawProduct[] }) {
  const [cartItems, setCartItems] = useState<string[]>([]);
  const [searchQuery, setSearchQuery] = useState('');

  // useMemo: derive stable filtered/shaped product list
  const products = useMemo(
    () =>
      rawProducts
        .filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
        .map(p => ({ id: p.id, name: p.name, price: p.price })),
    [rawProducts, searchQuery]
  );

  // useCallback: stable function references for memo'd child
  const handleAddToCart = useCallback((id: string) => {
    setCartItems(prev => [...prev, id]);
  }, []);

  const handleRemove = useCallback((id: string) => {
    setCartItems(prev => prev.filter(itemId => itemId !== id));
  }, []);

  return (
    <div>
      <input
        value={searchQuery}
        onChange={e => setSearchQuery(e.target.value)}
        placeholder="Search..."
      />
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
          onRemove={handleRemove}
        />
      ))}
    </div>
  );
}

In this pattern: searchQuery changes trigger a useMemo recomputation of products. Only the ProductCard instances whose product reference actually changed re-render. The onAddToCart and onRemove callbacks remain stable across all re-renders.

Custom Comparison Functions in React.memo

React.memo accepts an optional second argument: a custom areEqual function.

const Chart = React.memo(
  function Chart({ data, config, annotations }: ChartProps) {
    // Expensive chart rendering...
    return <canvas ref={canvasRef} />;
  },
  // Custom comparison: only re-render if these specific conditions are met
  (prevProps, nextProps) => {
    // Return true = "props are equal" = SKIP render
    // Return false = "props differ" = DO render
    if (prevProps.data.length !== nextProps.data.length) return false;
    if (prevProps.config.chartType !== nextProps.config.chartType) return false;
    if (prevProps.config.colorScheme !== nextProps.config.colorScheme) return false;
    // Ignore annotations changes for rendering purposes
    return true;
  }
);

Warning: the areEqual semantics are the opposite of shouldComponentUpdate. Returning true means "equal, skip render." Returning false means "different, render." Getting this backwards causes a component to never update โ€” a subtle and frustrating bug.

Custom comparisons are appropriate when:

Be cautious about complexity: if the custom comparison function itself runs longer than the component render, you've optimized in the wrong direction.

Three Scenarios Where React.memo Fails

Scenario 1: Context Consumers

React.memo only guards against prop changes. It has no effect on context subscriptions:

const MemoChild = React.memo(function Child() {
  const theme = useContext(ThemeContext); // This subscription bypasses memo
  return <div style={{ color: theme.color }}>Hello</div>;
});

// When ThemeContext value changes, MemoChild re-renders
// even if its props are completely unchanged.

Solutions: split contexts so components only subscribe to the slice they need, or memoize derived values from context inside the component.

Scenario 2: The children Prop

children is just another prop โ€” but it's almost always a new JSX element object created in the parent's render:

const MemoWrapper = React.memo(function Wrapper({ children }: { children: React.ReactNode }) {
  return <div className="wrapper">{children}</div>;
});

function Parent() {
  const [count, setCount] = useState(0);
  return (
    <MemoWrapper>
      {/* โŒ <p>Static text</p> creates a new React element object every render */}
      <p>Static text</p>
    </MemoWrapper>
  );
}

The children prop always has a new reference. Shallow comparison fails on it. MemoWrapper re-renders every time Parent does. The memo is useless here.

Scenario 3: Inline Object Props

// โŒ The single most common React.memo failure in real codebases
function Parent() {
  return (
    <MemoCard
      style={{ padding: 16, margin: 8 }}   // New object every render
      options={{ animated: true, lazy: false }}  // New object every render
    />
  );
}

The fix is to hoist constant objects outside the component, or use useMemo for objects that depend on props/state.

// โœ… Option 1: constant objects don't need useMemo
const CARD_STYLE = { padding: 16, margin: 8 };
const CARD_OPTIONS = { animated: true, lazy: false };

function Parent() {
  return <MemoCard style={CARD_STYLE} options={CARD_OPTIONS} />;
}

// โœ… Option 2: dynamic values need useMemo
function Parent({ isAnimated }: { isAnimated: boolean }) {
  const options = useMemo(
    () => ({ animated: isAnimated, lazy: false }),
    [isAnimated]
  );
  return <MemoCard style={CARD_STYLE} options={options} />;
}

React Compiler: Auto-Memoization in React 19

React 19 ships with React Compiler (formerly React Forget), a build-time tool that analyzes your component code and inserts memoization automatically. You write plain React code; the compiler figures out which values need to be cached.

// What you write
function ProductCard({ product, onAddToCart }) {
  const displayPrice = `$${product.price.toFixed(2)}`;
  return (
    <div>
      <h3>{product.name}</h3>
      <span>{displayPrice}</span>
      <button onClick={() => onAddToCart(product.id)}>Add</button>
    </div>
  );
}

// What the compiler generates (conceptually)
function ProductCard({ product, onAddToCart }) {
  const $ = useMemoCache(6);
  let t0;
  if ($[0] !== product.name) { t0 = <h3>{product.name}</h3>; $[0] = product.name; $[1] = t0; }
  else { t0 = $[1]; }

  let t1;
  if ($[2] !== product.price) {
    const displayPrice = `$${product.price.toFixed(2)}`;
    t1 = <span>{displayPrice}</span>;
    $[2] = product.price; $[3] = t1;
  } else { t1 = $[3]; }

  let t2;
  if ($[4] !== product.id || $[5] !== onAddToCart) {
    t2 = <button onClick={() => onAddToCart(product.id)}>Add</button>;
    $[4] = product.id; $[5] = onAddToCart; // ... cache t2
  }
  // ...
}

The compiler achieves finer granularity than hand-written useMemo โ€” it can memoize individual JSX elements within a render rather than the entire component output.

Prerequisite: your code must follow React's rules โ€” pure component functions, no direct state mutation, no side effects outside useEffect. Code that violates these rules will be skipped by the compiler and left as-is. Understanding the manual memoization system remains essential for:

  1. Writing compiler-friendly code in the first place
  2. Diagnosing cases where the compiler couldn't optimize your component
  3. Projects that haven't yet adopted React 19

When Not to Memoize

Memoization has real costs: additional function calls, cache entry allocation, and comparison work on every render. These costs are usually tiny โ€” but they add up across hundreds of components, and they complicate code maintenance.

Skip memoization when:

The right mental model: React.memo is not a "make this component better" decoration. It's a specific optimization for a specific problem: an expensive component with stable-enough props being re-rendered unnecessarily due to parent churn.

Summary

React.memo applies shallow equality (via Object.is per prop) to decide whether to skip rendering a component. Used alone, it almost always fails silently because function and object props change reference on every parent render. The complete system requires all three tools: React.memo as the re-render gate, useCallback to stabilize function props, and useMemo to stabilize object props and cache expensive computations. Three scenarios defeat memo regardless: context subscriptions, the children prop, and inline object literals. React Compiler in React 19 automates this system at compile time, but understanding the manual approach remains the foundation for writing high-quality React.

Rate this chapter
4.5  / 5  (15 ratings)

๐Ÿ’ฌ Comments