Rreact.wiki
← Blog

React.memo Deep Dive: When It Helps, When It Hurts, and How to Measure

React.memo Deep Dive: When It Helps, When It Hurts, and How to Measure

Learn how to profile memoization impact with React DevTools and `console.time`, and avoid common pitfalls like over-memoizing callbacks or breaking referential equality in nested lists.

Why React.memo Isn’t a Magic Bullet

React.memo is often reached for reflexively when components feel “slow.” But memoization has real costs: extra shallow prop comparisons on every render, increased memory usage, and subtle bugs when dependencies are mismanaged. Worse—when applied incorrectly—it can increase renders by breaking referential stability.

This article walks through concrete, measurable scenarios using React DevTools Profiler and console.time to show exactly when React.memo helps (e.g., large static lists), when it hurts (e.g., inline functions passed as props), and how to validate its impact—not guess.

We’ll build and profile a TaskList component rendering 200 tasks, each with an editable title and a delete button—then iteratively apply and measure React.memo.

The Baseline: Unmemoized List

Here’s our starting point—a simple list that re-renders entirely on any state change:

TSX
import { useState } from 'react';
 
type Task = { id: string; title: string };
 
function TaskItem({ task, onDelete }: { task: Task; onDelete: (id: string) => void }) {
  const [title, setTitle] = useState(task.title);
  
  return (
    <li>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button onClick={() => onDelete(task.id)}>Delete</button>
    </li>
  );
}
 
export default function TaskList() {
  const [tasks, setTasks] = useState<Task[]>(Array.from({ length: 200 }, (_, i) => ({
    id: `task-${i}`,
    title: `Task ${i}`
  })));
  
  const deleteTask = (id: string) => {
    setTasks(tasks.filter(t => t.id !== id));
  };
 
  return (
    <ul>
      {tasks.map(task => (
        <TaskItem key={task.id} task={task} onDelete={deleteTask} />
      ))}
    </ul>
  );
}

Without memoization, typing in one input triggers 200 re-renders, because deleteTask is recreated on every render—and thus every TaskItem receives new props.

Measuring the Problem

Open React DevTools → Profiler tab → Record while typing in an input. You’ll see ~200 TaskItem commits per keystroke. Now add timing:

TSX
function TaskItem({ task, onDelete }: { task: Task; onDelete: (id: string) => void }) {
  console.time('TaskItem render');
  const [title, setTitle] = useState(task.title);
  console.timeEnd('TaskItem render');
  
  // ... rest unchanged
}

In the console, you’ll see 200+ TaskItem render: X.XXms logs per keystroke—confirming the scale of wasted work.

Applying React.memo — and Getting It Wrong

A naive fix adds React.memo, but passes an inline function:

TSX
const MemoizedTaskItem = React.memo(TaskItem);
 
// In TaskList render:
{tasks.map(task => (
  <MemoizedTaskItem 
    key={task.id} 
    task={task} 
    onDelete={(id) => deleteTask(id)} // ❌ New function every render!
  />
))}

This worsens performance. React.memo compares props shallowly—so onDelete is always a new reference → every TaskItem re-renders anyway. Worse, the memoization overhead adds ~0.02ms per item on top of the full render.

✅ Fix: Move the callback outside map, or use useCallback:

TSX
const deleteTask = useCallback((id: string) => {
  setTasks(prev => prev.filter(t => t.id !== id));
}, [setTasks]);
 
// Then pass directly:
<MemoizedTaskItem key={task.id} task={task} onDelete={deleteTask} />

Now onDelete stays stable across renders—enabling React.memo to skip unchanged items.

The Right Way: Memoize Strategically

Here’s the corrected, optimized version:

TSX
import { useState, useCallback, memo } from 'react';
 
type Task = { id: string; title: string };
 
const TaskItem = memo(({ task, onDelete }: { task: Task; onDelete: (id: string) => void }) => {
  const [title, setTitle] = useState(task.title);
  
  return (
    <li>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button onClick={() => onDelete(task.id)}>Delete</button>
    </li>
  );
});
 
export default function TaskList() {
  const [tasks, setTasks] = useState<Task[]>(Array.from({ length: 200 }, (_, i) => ({
    id: `task-${i}`,
    title: `Task ${i}`
  })));
  
  const deleteTask = useCallback((id: string) => {
    setTasks(prev => prev.filter(t => t.id !== id));
  }, [setTasks]);
 
  return (
    <ul>
      {tasks.map(task => (
        <TaskItem key={task.id} task={task} onDelete={deleteTask} />
      ))}
    </ul>
  );
}

Now only the edited TaskItem re-renders. Confirm in DevTools: one TaskItem commit per keystroke. Console timing shows just one TaskItem render log.

When memoization backfires: The Callback Trap

Consider this common anti-pattern:

TSX
// ❌ Never do this inside memoized components
const TaskItem = memo(({ task, onDelete }: { task: Task; onDelete: (id: string) => void }) => {
  const handleDelete = useCallback(() => onDelete(task.id), [onDelete, task.id]);
  
  return (
    <button onClick={handleDelete}>Delete</button>
  );
});

handleDelete is stable per instance, but React.memo can’t skip renders unless all props are stable—including task. If task is a new object (e.g., from useReducer with object spread), task.id changes → handleDelete’s dependency array changes → new function → re-render. Worse, you’ve added useCallback overhead for no benefit.

✅ Instead, pass task.id directly to the event handler:

TSX
<button onClick={() => onDelete(task.id)}>Delete</button>

No extra hooks, no dependency complexity—and React.memo works reliably if onDelete is stable.

Measuring Real-World Tradeoffs

Not all lists need memo. For small lists (< 20 items), the cost of shallow comparison (~0.01ms per item) may exceed the savings. Profile both:

TSX
// Add to TaskList render:
console.time('TaskList render');
// ... return JSX
console.timeEnd('TaskList render');
 
// And inside TaskItem:
console.time('TaskItem shallow compare');
const isSame = prevProps.task.id === nextProps.task.id && 
               prevProps.onDelete === nextProps.onDelete;
console.timeEnd('TaskItem shallow compare');
return isSame;

Run with 50 vs. 200 items. You’ll see comparison time scale linearly—but render time scales quadratically without memo. At 50 items, memo saves ~3ms total; at 200, it saves ~48ms. That’s meaningful on low-end devices.

Pitfalls to Avoid

  • Over-memoizing primitives: React.memo on a component accepting only string or number props rarely helps—the comparison cost exceeds the render cost.
  • Breaking referential equality with objects: Never pass { id: task.id } as a prop. Use task.id directly—or useMemo(() => ({ id: task.id }), [task.id]).
  • Forgetting key: React.memo + missing/incorrect key causes stale props or duplicate renders. Always use stable, unique keys.
  • Ignoring context updates: React.memo doesn’t prevent re-renders from useContext. Wrap context consumers in their own memoized components if needed.

Final Checklist Before Memoizing

Before adding React.memo, ask:

  1. Does this component render frequently and expensively? (Confirm with DevTools Profiler.)
  2. Are its props mostly stable across renders? (Log prevProps vs nextProps in areEqual.)
  3. Is the component deep in the tree, so skipping it prevents cascading renders?
  4. Are you prepared to manage dependency arrays correctly for callbacks?

If the answer to #1 is “no,” skip memoization. Premature optimization here adds complexity without payoff.

Conclusion: Measure, Don’t Assume

React.memo is a surgical tool—not a global toggle. Its value emerges only when you measure actual render counts and timings before and after, with realistic data sizes and interactions.

Use React DevTools Profiler to count commits. Use console.time to isolate expensive subtrees. And remember: the fastest render is the one that doesn’t happen—but the second-fastest is the one that’s easy to understand and maintain.

Start small. Profile relentlessly. Optimize only what matters.