React.memouseMemouseCallbackPerformanceMemoization

React.memo, useMemo & useCallback

Master React's three memoization tools. Understand what each one caches, when to reach for them, and when they hurt more than they help — the nuance interviewers are looking for.

30 min read13 sections
01

Overview

React re-renders a component whenever its parent re-renders — even if the child's props haven't changed. In large apps, this cascading behavior creates unnecessary work: rebuilding Virtual DOM trees, running diffing, and sometimes touching the real DOM for zero visual change.

React gives you three memoization tools to short-circuit this waste: React.memo skips re-rendering an entire component if its props are the same, useMemo caches the result of an expensive calculation so it isn't recomputed every render, and useCallback caches a function reference so child components receiving it as a prop don't see a "new" function every time.

The key skill isn't knowing the API — it's knowing when to use each one and when memoization adds overhead without benefit. That nuance is exactly what interviewers test.

Why this matters

"How would you optimize a slow React component?" is one of the most common frontend interview questions. Understanding these three tools — and their trade-offs — is the foundation of every good answer.

02

The Problem: Unnecessary Re-renders

To understand why memoization matters, you need to understand React's default re-rendering behavior.

🔄

Default Behavior

When a component re-renders, ALL its children re-render too — regardless of whether their props changed. React doesn't check props by default.

🌊

The Cascade

A state change at the top of a tree triggers re-renders all the way down. In a 100-component tree, one setState can re-render all 100 components.

💰

The Cost

Each re-render means: calling the component function, creating Virtual DOM nodes, diffing, and potentially updating the real DOM. This adds up fast.

unnecessary-rerenders.jsxjavascript
function Parent() {
  const [count, setCount] = useState(0);

  console.log("Parent rendered");

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

      {/* These re-render EVERY time count changes */}
      {/* even though they don't use count at all! */}
      <ExpensiveChart data={staticData} />
      <UserProfile user={currentUser} />
      <Footer />
    </div>
  );
}

function ExpensiveChart({ data }) {
  console.log("ExpensiveChart rendered"); // Logs every click!
  // Imagine this takes 50ms to render...
  return <canvas>{/* complex chart */}</canvas>;
}

function Footer() {
  console.log("Footer rendered"); // Logs every click!
  return <footer2025</footer>;
}

// Click the button 10 times:
// "Parent rendered" × 10
// "ExpensiveChart rendered" × 10  ← wasted work!
// "Footer rendered" × 10          ← wasted work!

Why Does React Work This Way?

React could check if props changed before re-rendering each child, but that comparison itself has a cost. For most components, re-rendering is cheap enough that the check would be wasted overhead. React optimizes for the common case and gives you opt-in tools (memo, useMemo, useCallback) for the cases where re-rendering is actually expensive.

Re-render Decision Treetext
Parent state changes


┌─────────────────────────────────────────┐
Does child use React.memo?             │
│                                         │
NORe-render child (always)         │
│                                         │
YESDid props change? (shallow ===)  │
│         │                               │
│         ├─ YESRe-render child
│         └─ NOSkip re-render ✓       │
└─────────────────────────────────────────┘

Not all re-renders are bad

A re-render that produces the same Virtual DOM is cheap — React's diffing will find no changes and skip DOM updates. The problem is when re-renders are frequent (typing, scrolling) and the component is expensive (large lists, complex calculations, heavy DOM). That's when memoization pays off.

03

React.memo (Component Memoization)

React.memo is a higher-order component that wraps a component and skips re-rendering it if its props haven't changed. It performs a shallow comparison of the previous and next props.

react-memo-syntax.jsxjavascript
// Basic usage
const MemoizedComponent = React.memo(function MyComponent({ name, score }) {
  console.log("Rendered!");
  return <div>{name}: {score}</div>;
});

// With arrow function
const MemoizedChart = React.memo(({ data }) => {
  console.log("Chart rendered!");
  return <canvas>{/* expensive chart */}</canvas>;
});

// With custom comparison function
const MemoizedItem = React.memo(
  ({ item, onSelect }) => {
    return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
  },
  (prevProps, nextProps) => {
    // Return true to SKIP re-render (props are "equal")
    // Return false to RE-RENDER (props "changed")
    return prevProps.item.id === nextProps.item.id
        && prevProps.item.name === nextProps.item.name;
  }
);

Before vs After React.memo

before-memo.jsxjavascript
// ❌ WITHOUT React.memo — ExpensiveChart re-renders every click

function Dashboard() {
  const [filter, setFilter] = useState("all");

  return (
    <div>
      <select onChange={e => setFilter(e.target.value)}>
        <option value="all">All</option>
        <option value="active">Active</option>
      </select>

      {/* Re-renders every time filter changes, even though
          chartData never changes! Takes 80ms each time. */}
      <ExpensiveChart data={chartData} />

      <FilteredList filter={filter} items={items} />
    </div>
  );
}

function ExpensiveChart({ data }) {
  // Expensive: processes 10,000 data points
  const processed = data.map(d => complexCalculation(d));
  return <canvas>{/* render chart */}</canvas>;
}
after-memo.jsxjavascript
// ✅ WITH React.memo — ExpensiveChart skips re-render

function Dashboard() {
  const [filter, setFilter] = useState("all");

  return (
    <div>
      <select onChange={e => setFilter(e.target.value)}>
        <option value="all">All</option>
        <option value="active">Active</option>
      </select>

      {/* React.memo checks: did "data" prop change?
          chartData is the same reference → skip re-render ✓ */}
      <MemoizedChart data={chartData} />

      <FilteredList filter={filter} items={items} />
    </div>
  );
}

const MemoizedChart = React.memo(function ExpensiveChart({ data }) {
  // Only runs when data actually changes
  const processed = data.map(d => complexCalculation(d));
  return <canvas>{/* render chart */}</canvas>;
});

How Shallow Comparison Works

shallow-comparison.jsjavascript
// React.memo does this internally (simplified):
function shallowEqual(prevProps, nextProps) {
  const prevKeys = Object.keys(prevProps);
  const nextKeys = Object.keys(nextProps);

  if (prevKeys.length !== nextKeys.length) return false;

  for (const key of prevKeys) {
    // Strict equality (===) — NOT deep comparison
    if (prevProps[key] !== nextProps[key]) return false;
  }
  return true;
}

// ✅ Primitives work great:
"hello" === "hello"   // true — same string
42 === 42             // true — same number
true === true         // true — same boolean

// ❌ Objects/arrays fail even with same content:
{ a: 1 } === { a: 1 }     // false — different references!
[1, 2] === [1, 2]         // false — different references!
(() => {}) === (() => {})  // false — different references!

// This is why useMemo and useCallback exist —
// to stabilize object and function references.

When React.memo is effective

React.memo works best when: (1) the component is expensive to render, (2) it re-renders often with the same props, and (3) its props are primitives or stable references. If you pass new objects or functions as props every render, memo can't help — that's where useMemo and useCallback come in.

04

useMemo (Value Memoization)

useMemo caches the return value of a function between renders. It only recomputes when one of its dependencies changes. This serves two purposes: skipping expensive calculations and stabilizing object/array references for React.memo.

usememo-syntax.jsxjavascript
const memoizedValue = useMemo(() => {
  // This function runs only when dependencies change
  return expensiveCalculation(a, b);
}, [a, b]); // ← dependency array

// Rules:
// - Returns a VALUE (number, string, object, array, JSX)
// - Recomputes only when deps change (shallow ===)
// - First render: always runs the function
// - Subsequent renders: returns cached value if deps unchanged

Use Case 1: Expensive Calculations

expensive-calculation.jsxjavascript
// ❌ WITHOUT useMemo — recalculates on EVERY render
function ProductList({ products, filter }) {
  const [sortOrder, setSortOrder] = useState("asc");

  // This runs every render — even when only sortOrder changed!
  // Filtering 50,000 products takes ~120ms
  const filtered = products.filter(p =>
    p.name.toLowerCase().includes(filter.toLowerCase())
  );

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

// ✅ WITH useMemo — recalculates only when inputs change
function ProductList({ products, filter }) {
  const [sortOrder, setSortOrder] = useState("asc");

  // Only recomputes when products or filter change
  // Changing sortOrder does NOT trigger refiltering
  const filtered = useMemo(() => {
    return products.filter(p =>
      p.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [products, filter]);

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

Use Case 2: Stabilizing References for React.memo

stable-reference.jsxjavascript
// ❌ WITHOUT useMemo — new object every render breaks memo
function App() {
  const [count, setCount] = useState(0);

  // New object reference every render!
  const config = { theme: "dark", locale: "en" };

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
      {/* MemoizedSidebar re-renders every click because
          config is a new object reference each time */}
      <MemoizedSidebar config={config} />
    </>
  );
}

// ✅ WITH useMemo — stable reference, memo works
function App() {
  const [count, setCount] = useState(0);

  // Same reference across renders (no deps = never changes)
  const config = useMemo(() => ({ theme: "dark", locale: "en" }), []);

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
      {/* MemoizedSidebar skips re-render ✓
          config is the same reference */}
      <MemoizedSidebar config={config} />
    </>
  );
}

useMemo is about skipping work

Think of useMemo as a cache with a dependency-based invalidation key. If the deps haven't changed, return the cached value. If they have, recompute and cache the new value. The function you pass should be pure — no side effects.

05

useCallback (Function Memoization)

useCallback caches a function definition between renders. It returns the same function reference as long as its dependencies haven't changed. This is critical when passing callbacks to memoized child components.

usecallback-syntax.jsxjavascript
const memoizedFn = useCallback(() => {
  doSomething(a, b);
}, [a, b]); // ← dependency array

// Equivalent to:
const memoizedFn = useMemo(() => {
  return () => doSomething(a, b);
}, [a, b]);

// useCallback(fn, deps) === useMemo(() => fn, deps)
// useCallback is just syntactic sugar for memoizing functions

The Problem: Functions Break React.memo

function-breaks-memo.jsxjavascript
// ❌ WITHOUT useCallback — memo is useless

function TodoApp() {
  const [todos, setTodos] = useState(initialTodos);
  const [filter, setFilter] = useState("all");

  // New function reference EVERY render!
  const handleToggle = (id) => {
    setTodos(prev => prev.map(t =>
      t.id === id ? { ...t, done: !t.done } : t
    ));
  };

  return (
    <div>
      <FilterBar filter={filter} onChange={setFilter} />
      {todos.map(todo => (
        // Even though TodoItem is wrapped in React.memo,
        // it re-renders every time because handleToggle
        // is a NEW function reference on every render
        <MemoizedTodoItem
          key={todo.id}
          todo={todo}
          onToggle={handleToggle}  // ← new ref every time!
        />
      ))}
    </div>
  );
}

const MemoizedTodoItem = React.memo(function TodoItem({ todo, onToggle }) {
  console.log(`Rendering: ${todo.text}`);
  return (
    <li onClick={() => onToggle(todo.id)}>
      {todo.done ? "✅" : "⬜"} {todo.text}
    </li>
  );
});
// ALL items log on every filter change — memo can't help!
function-fixed-with-usecallback.jsxjavascript
// ✅ WITH useCallback — memo works correctly

function TodoApp() {
  const [todos, setTodos] = useState(initialTodos);
  const [filter, setFilter] = useState("all");

  // Same function reference across renders
  // Only changes if... well, it has no deps, so never
  const handleToggle = useCallback((id) => {
    setTodos(prev => prev.map(t =>
      t.id === id ? { ...t, done: !t.done } : t
    ));
  }, []); // ← empty deps: uses functional setState, no external deps

  return (
    <div>
      <FilterBar filter={filter} onChange={setFilter} />
      {todos.map(todo => (
        <MemoizedTodoItem
          key={todo.id}
          todo={todo}
          onToggle={handleToggle}  // ← same ref every time ✓
        />
      ))}
    </div>
  );
}

// Now when filter changes:
// - TodoApp re-renders
// - handleToggle is the same reference (useCallback)
// - Each todo object is the same reference (state didn't change)
// - React.memo sees same props → skips re-render ✓
// Only FilterBar re-renders — exactly what we want.

useCallback alone does nothing

useCallback only helps when the function is passed to a component wrapped in React.memo (or used as a dependency in another hook). Without memo on the child, the child re-renders anyway because React's default behavior is to re-render all children. useCallback + React.memo work as a pair.

06

Key Differences

This is the section interviewers care about most. You need to clearly articulate what each tool memoizes, when to use it, and how they work together.

React.memouseMemouseCallback
What it isHigher-order componentHookHook
What it memoizesEntire component renderComputed valueFunction reference
ReturnsMemoized componentCached valueCached function
ComparisonShallow props comparisonDependency array (===)Dependency array (===)
PreventsComponent re-renderExpensive recalculationFunction recreation
Use whenChild re-renders with same propsExpensive computation or stable object refPassing callbacks to memo'd children

How They Work Together

working-together.jsxjavascript
function SearchPage({ allProducts }) {
  const [query, setQuery] = useState("");
  const [cart, setCart] = useState([]);

  // useMemo: cache expensive filtered result
  // Only recomputes when allProducts or query change
  const results = useMemo(() => {
    return allProducts.filter(p =>
      p.name.toLowerCase().includes(query.toLowerCase())
    );
  }, [allProducts, query]);

  // useCallback: stable function reference
  // Only changes when... never (uses functional setState)
  const addToCart = useCallback((product) => {
    setCart(prev => [...prev, product]);
  }, []);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <p>{cart.length} items in cart</p>

      {/* React.memo: skip re-render if props unchanged */}
      <MemoizedProductGrid
        products={results}    // ← stable ref (useMemo)
        onAddToCart={addToCart} // ← stable ref (useCallback)
      />
    </div>
  );
}

// React.memo: only re-renders when products or onAddToCart change
const MemoizedProductGrid = React.memo(function ProductGrid({
  products,
  onAddToCart,
}) {
  console.log("ProductGrid rendered");
  return (
    <div className="grid">
      {products.map(p => (
        <ProductCard key={p.id} product={p} onAdd={onAddToCart} />
      ))}
    </div>
  );
});

// When user adds to cart (setCart):
// - SearchPage re-renders
// - results: same ref (query didn't change) ✓
// - addToCart: same ref (useCallback, no deps) ✓
// - React.memo: props unchanged → skip ProductGrid render ✓
// Only the cart count text updates. Zero wasted work.
Decision Flowcharttext
Is the child component expensive to render?

    ├─ NODon't bother with memoization

    └─ YESWrap child in React.memo

              ├─ Are you passing objects/arrays as props?
              │   └─ YESWrap them in useMemo

              └─ Are you passing functions as props?
                  └─ YESWrap them in useCallback

The trio works as a system

React.memo is the gatekeeper — it decides whether to re-render. useMemo and useCallback are the enablers — they ensure the props React.memo compares are actually stable references. Using React.memo without stabilizing props is like locking the door but leaving the window open.

07

When NOT to Use Them

Memoization isn't free. Every useMemo and useCallback allocates memory to store the cached value, runs a dependency comparison on every render, and adds cognitive complexity to your code. Sometimes the cure is worse than the disease.

🧠

Memory Cost

Each memoized value stays in memory until the component unmounts or deps change. Memoizing 1,000 list items means 1,000 cached values in memory.

⚖️

Comparison Cost

Every render, React compares each dependency with ===. For useMemo with 5 deps, that's 5 comparisons per render — often more work than just recomputing a simple value.

Don't Memoize When...

dont-memoize.jsxjavascript
// ❌ Memoizing a cheap calculation
const fullName = useMemo(() => {
  return `${firstName} ${lastName}`;
}, [firstName, lastName]);
// String concatenation is trivial. The useMemo overhead
// (storing value + comparing deps) costs MORE than just
// computing it every render.
// ✅ Just compute it:
const fullName = `${firstName} ${lastName}`;


// ❌ Memoizing when the component isn't wrapped in React.memo
const handleClick = useCallback(() => {
  doSomething();
}, []);
// If the child receiving handleClick isn't memoized,
// it re-renders anyway. useCallback is wasted.
// ✅ Just use a regular function:
const handleClick = () => doSomething();


// ❌ Memoizing with deps that change every render
const data = useMemo(() => {
  return transform(items);
}, [items]); // If items is a new array every render,
             // useMemo recomputes every time anyway.
// ✅ Fix the source: stabilize items first, or don't memoize.


// ❌ Memoizing primitive props for React.memo
// React.memo already handles primitives perfectly:
<MemoizedLabel text="Hello" count={5} active={true} />
// All props are primitives → shallow comparison works
// No need for useMemo or useCallback here.
ScenarioMemoize?Why
Expensive computation (sorting 10k items)✅ YesComputation cost >> comparison cost
String concatenation, simple math❌ NoComputation is trivial, memo adds overhead
Callback passed to React.memo child✅ YesStabilizes reference so memo can work
Callback passed to non-memoized child❌ NoChild re-renders anyway, useCallback is wasted
Object prop for React.memo child✅ YesPrevents new reference from breaking memo
Component renders once (static page)❌ NoNo re-renders to optimize

The golden rule

Measure before you memoize. Use React DevTools Profiler to identify which components actually re-render too often and how long they take. Optimize the bottlenecks, not everything. Premature memoization adds complexity without measurable benefit.

08

Real-World Example

Let's build a realistic dashboard component and optimize it step by step. This mirrors what you'd encounter in a production app or a system design interview.

The Scenario

A dashboard with a search input, a list of 1,000 employees, and a summary stats panel. Typing in the search box causes noticeable lag.

dashboard-unoptimized.jsxjavascript
// ❌ UNOPTIMIZED — everything re-renders on every keystroke

function EmployeeDashboard({ employees }) {
  const [search, setSearch] = useState("");
  const [selectedId, setSelectedId] = useState(null);

  // Runs on EVERY render (every keystroke)
  const filtered = employees.filter(emp =>
    emp.name.toLowerCase().includes(search.toLowerCase())
  );

  // Runs on EVERY render
  const stats = {
    total: employees.length,
    avgSalary: employees.reduce((s, e) => s + e.salary, 0) / employees.length,
    departments: [...new Set(employees.map(e => e.dept))].length,
  };

  // New function on EVERY render
  const handleSelect = (id) => setSelectedId(id);

  return (
    <div>
      <input
        value={search}
        onChange={e => setSearch(e.target.value)}
        placeholder="Search employees..."
      />
      <StatsPanel stats={stats} />
      <EmployeeList employees={filtered} onSelect={handleSelect} />
      <EmployeeDetail id={selectedId} />
    </div>
  );
}

// Typing "John":
// - 4 keystrokes = 4 re-renders of EVERYTHING
// - StatsPanel re-renders 4x (stats is new object each time)
// - EmployeeList re-renders 4x (filtered is new array + handleSelect is new fn)
// - Each of 1,000 EmployeeRow components re-renders 4x
// Total: ~4,000 component renders for 4 keystrokes 😱
dashboard-optimized.jsxjavascript
// ✅ OPTIMIZED — surgical re-renders only where needed

function EmployeeDashboard({ employees }) {
  const [search, setSearch] = useState("");
  const [selectedId, setSelectedId] = useState(null);

  // useMemo: only recompute when employees or search change
  const filtered = useMemo(() => {
    return employees.filter(emp =>
      emp.name.toLowerCase().includes(search.toLowerCase())
    );
  }, [employees, search]);

  // useMemo: only recompute when employees change (not on search!)
  const stats = useMemo(() => ({
    total: employees.length,
    avgSalary: employees.reduce((s, e) => s + e.salary, 0) / employees.length,
    departments: [...new Set(employees.map(e => e.dept))].length,
  }), [employees]);

  // useCallback: stable function reference
  const handleSelect = useCallback((id) => {
    setSelectedId(id);
  }, []);

  return (
    <div>
      <input
        value={search}
        onChange={e => setSearch(e.target.value)}
        placeholder="Search employees..."
      />
      <MemoizedStatsPanel stats={stats} />
      <MemoizedEmployeeList employees={filtered} onSelect={handleSelect} />
      <EmployeeDetail id={selectedId} />
    </div>
  );
}

// React.memo: skip re-render if stats object is the same ref
const MemoizedStatsPanel = React.memo(function StatsPanel({ stats }) {
  return (
    <div className="stats">
      <span>Total: {stats.total}</span>
      <span>Avg Salary: ${stats.avgSalary.toFixed(0)}</span>
      <span>Departments: {stats.departments}</span>
    </div>
  );
});

// React.memo: skip re-render if employees and onSelect are same refs
const MemoizedEmployeeList = React.memo(function EmployeeList({
  employees,
  onSelect,
}) {
  return (
    <ul>
      {employees.map(emp => (
        <MemoizedEmployeeRow
          key={emp.id}
          employee={emp}
          onSelect={onSelect}
        />
      ))}
    </ul>
  );
});

// React.memo: individual rows skip re-render too
const MemoizedEmployeeRow = React.memo(function EmployeeRow({
  employee,
  onSelect,
}) {
  return (
    <li onClick={() => onSelect(employee.id)}>
      {employee.name} — {employee.dept}
    </li>
  );
});

What Changed

1

Typing in search box

Only the input and MemoizedEmployeeList re-render (filtered changes). MemoizedStatsPanel skips — stats ref is stable (employees didn't change). Individual rows that match the same data skip too.

2

Selecting an employee

Only EmployeeDetail re-renders (selectedId changed). MemoizedStatsPanel and MemoizedEmployeeList both skip — their props (stats, filtered, handleSelect) are all stable references.

3

New employees data from API

Everything re-renders because the employees prop changed. This is correct — the data actually changed, so all derived values need to recompute.

The optimization payoff

Typing "John" now triggers ~4 list re-renders (with filtered results) instead of ~4,000 component renders. The stats panel doesn't re-render at all during search. Selection doesn't re-render the list. Each interaction only updates what actually changed.

09

Performance Insights

Memoization is one tool in the performance toolkit. Here's how to think about it in the broader context of React performance.

✓ Done

Profile Before Optimizing

Use React DevTools Profiler to identify which components re-render most often and how long they take. Optimize the top offenders first — don't guess.

✓ Done

Combine memo with Virtualization

For long lists (1000+ items), React.memo on individual items helps, but virtualization (react-window, react-virtuoso) is even better — it removes items from the DOM entirely.

✓ Done

Colocate State Before Memoizing

Moving state closer to where it's used is often simpler and more effective than memoization. If only one child needs the state, put it there — other children won't re-render at all.

→ Could add

Use the React Compiler (React 19+)

The React Compiler automatically adds memoization at build time. It analyzes your code and inserts useMemo/useCallback where beneficial — making manual memoization unnecessary in many cases.

→ Could add

Consider Component Composition

Passing children as props (composition) can avoid re-renders without any memoization. The children are created by the parent's parent, so they don't recreate when the middle component re-renders.

composition-pattern.jsxjavascript
// ✅ Composition: no memoization needed!

// Instead of this (ExpensiveTree re-renders on every count change):
function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        {count}
      </button>
      <ExpensiveTree />  {/* re-renders every click */}
    </div>
  );
}

// Do this (lift the stateful part into its own component):
function App() {
  return (
    <div>
      <Counter />
      <ExpensiveTree />  {/* never re-renders! */}
    </div>
  );
}

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(c => c + 1)}>
      {count}
    </button>
  );
}
// State is colocated in Counter. App doesn't re-render.
// ExpensiveTree doesn't re-render. No memo needed.

The optimization hierarchy

Before reaching for memo/useMemo/useCallback, try these first: (1) colocate state, (2) lift content up via composition, (3) use children prop pattern. These are simpler, have zero runtime cost, and often eliminate the problem entirely. Memoization is the last resort, not the first.

10

Common Mistakes

These mistakes are common in codebases and interviews. Understanding them shows depth beyond surface-level API knowledge.

🔨

Wrapping everything in useMemo

Developers add useMemo to every variable 'just in case.' For cheap operations (string concat, simple math, creating small objects), the memoization overhead exceeds the computation cost.

Only memoize when the computation is measurably expensive (>1ms) or when you need a stable reference for React.memo. Profile first.

🔗

useCallback without React.memo on the child

useCallback stabilizes a function reference, but if the child component isn't wrapped in React.memo, it re-renders anyway. The useCallback is doing nothing.

useCallback and React.memo are a pair. If you add useCallback for a prop, make sure the receiving component is memoized. Otherwise, remove the useCallback.

📋

Wrong or missing dependencies

Omitting dependencies causes stale closures — the memoized function captures old values. Adding unnecessary dependencies causes the memo to recompute every render, defeating the purpose.

Include all values from the component scope that the function reads. Use the eslint-plugin-react-hooks exhaustive-deps rule — it catches these automatically.

🆕

Deps that change every render

useMemo(() => transform(data), [data]) is useless if data is a new array/object every render. The memo recomputes every time because the dep reference changed.

Stabilize the dependency first. If data comes from a parent, memoize it there. If it comes from a hook, check if the hook returns stable references.

🏗️

Inline objects/arrays in JSX despite React.memo

<MemoChild style={{ color: 'red' }} items={[1,2,3]} /> creates new references every render. React.memo compares with === and sees 'changed' props every time.

Hoist constants outside the component, or wrap in useMemo: const style = useMemo(() => ({ color: 'red' }), []).

🧩

Memoizing the wrong level

Memoizing a parent component doesn't help if the expensive work is in a child. And memoizing a leaf component doesn't help if the parent is the bottleneck.

Use the Profiler to find exactly which component is slow. Memoize at that level. Often, memoizing list items (not the list container) gives the biggest win.

11

Interview Questions

These questions come up in nearly every React-focused frontend interview. Practice explaining each one out loud — interviewers value clarity and nuance.

Q:What is the difference between useMemo and useCallback?

A: useMemo caches a computed value — it runs a function and remembers the return value. useCallback caches the function itself — it returns the same function reference across renders. In fact, useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). Use useMemo for expensive calculations or stable object references. Use useCallback for stable function references passed to memoized children.

Q:What does React.memo do and when would you use it?

A: React.memo is a higher-order component that skips re-rendering if the component's props haven't changed (shallow comparison). Use it when a component is expensive to render and frequently receives the same props — typically list items, charts, or complex UI sections. It's most effective when combined with useMemo/useCallback to ensure prop references are stable.

Q:When would you NOT use memoization?

A: Don't memoize when: (1) the computation is cheap (string concat, simple math), (2) the component is lightweight and re-renders are fast, (3) props change on every render anyway (making memo useless), or (4) the component rarely re-renders. Memoization has costs — memory for cached values and CPU for dependency comparisons. If the cost of memoizing exceeds the cost of recomputing, it's a net negative.

Q:How would you optimize a React component that re-renders too often?

A: Step 1: Profile with React DevTools to identify the bottleneck. Step 2: Try state colocation — move state closer to where it's used. Step 3: Try composition — pass expensive children as props. Step 4: If still slow, wrap the expensive child in React.memo, stabilize object props with useMemo, and stabilize function props with useCallback. Step 5: For long lists, add virtualization.

Q:Why does passing an inline function as a prop cause re-renders?

A: In JavaScript, () => {} !== () => {} — every function literal creates a new reference. When a parent re-renders and passes onClick={() => doSomething()}, the child sees a 'new' prop every time. If the child is wrapped in React.memo, memo's shallow comparison sees different references and re-renders anyway. useCallback fixes this by returning the same function reference across renders.

Q:What is shallow comparison and why does it matter for React.memo?

A: Shallow comparison checks if each prop is strictly equal (===) to its previous value. Primitives (strings, numbers, booleans) compare by value — 'hello' === 'hello' is true. Objects and arrays compare by reference — {a:1} === {a:1} is false because they're different objects in memory. This is why inline objects and functions break React.memo — they create new references every render.

Q:Can you explain the relationship between useCallback and React.memo?

A: They work as a pair. React.memo on a child component says 'skip re-render if props are the same.' useCallback in the parent says 'keep this function reference stable so memo can detect it as unchanged.' Without React.memo, useCallback is wasted (child re-renders anyway). Without useCallback, React.memo is defeated by new function references. You need both for the optimization to work.

Q:What is the React Compiler and how does it relate to memoization?

A: The React Compiler (React 19+) is a build-time tool that automatically analyzes your components and inserts memoization where beneficial. It can add the equivalent of useMemo, useCallback, and React.memo without you writing them. This means manual memoization becomes less necessary — the compiler handles it. However, understanding the concepts is still important for debugging and for codebases not yet using the compiler.

12

Practice Section

Work through these scenarios to test your understanding. Try to identify the problem and solution before reading the answer.

1

Why Is This Component Re-rendering?

A MemoizedChart component wrapped in React.memo re-renders every time the parent's unrelated state changes. The parent passes data={getChartData()} as a prop. Why does memo fail here?

Answer: getChartData() is called during render, creating a new array/object reference every time. React.memo's shallow comparison sees a different reference and re-renders. Fix: wrap in useMemo — const data = useMemo(() => getChartData(), [deps]) — so the reference is stable when the underlying data hasn't changed.

2

Optimize This Component

A parent component has a search input and renders 500 UserCard components. Each UserCard receives user={user} and onSelect={(id) => setSelected(id)}. Typing in the search box causes all 500 cards to re-render. How would you optimize this?

Answer: Three changes: (1) Wrap UserCard in React.memo so it skips re-render when props are unchanged. (2) Wrap onSelect in useCallback — const onSelect = useCallback((id) => setSelected(id), []) — so the function reference is stable. (3) If the user objects come from filtered results, wrap the filter in useMemo so unchanged user objects keep their references.

3

Is This useMemo Necessary?

const greeting = useMemo(() => `Hello, ${user.name}!`, [user.name]); — Is this useMemo justified?

Answer: No. String template concatenation is trivially cheap — nanoseconds. The useMemo overhead (storing the cached string, comparing user.name on every render) likely costs more than just computing the string. Remove the useMemo and write: const greeting = \`Hello, \${user.name}!\`. Reserve useMemo for computations that take >1ms or for stabilizing object references.

4

Stale Closure Bug

A useCallback has an empty dependency array but references a state variable: const handleSubmit = useCallback(() => { submitForm(formData); }, []). Users report that the form submits old data. Why?

Answer: Empty deps [] means the callback is created once and never updated. It captures the initial value of formData in its closure. When formData changes, the callback still sees the old value. Fix: either add formData to the dependency array — [formData] — or use a ref: const formDataRef = useRef(formData) and read formDataRef.current inside the callback.

5

Choose the Right Tool

You have a component that: (A) receives a large dataset and filters it, (B) passes a click handler to a memoized child list, (C) renders a static header that never changes. What optimization would you apply to each?

Answer: (A) useMemo for the filtered result — avoids refiltering when unrelated state changes. (B) useCallback for the click handler — stabilizes the reference so the memoized child list can skip re-renders. (C) Either React.memo on the header component, or better yet, extract it as a sibling component so it's outside the re-render scope entirely (composition pattern).

13

Cheat Sheet (Quick Revision)

One-screen summary for quick revision before interviews.

Quick Revision Cheat Sheet

React.memo: HOC that skips re-render if props unchanged (shallow ===). Wrap expensive child components.

useMemo: Caches a computed value. Recomputes only when dependencies change. For expensive calculations + stable object refs.

useCallback: Caches a function reference. Same as useMemo(() => fn, deps). For stable callbacks passed to memo'd children.

Shallow comparison: Compares with ===. Primitives compare by value. Objects/arrays compare by reference (new {} !== new {}).

The trio: React.memo = gatekeeper. useMemo = stable object refs. useCallback = stable function refs. They work together.

useCallback alone: Does nothing if the child isn't wrapped in React.memo. They're always a pair.

When to memoize: Component is expensive + re-renders often with same props. Measure first with React DevTools Profiler.

When NOT to memoize: Cheap computations, rarely re-rendering components, props that change every render anyway.

Before memoizing, try: State colocation (move state down), composition (children as props), component extraction.

Dependency array: Include all values from component scope that the function reads. Use exhaustive-deps ESLint rule.

Stale closures: Missing deps = callback captures old values. Always include deps or use refs for latest values.

React Compiler: React 19+ auto-inserts memoization at build time. Manual memo becomes less necessary but concepts still matter.

Inline objects in JSX: style={{}} creates new ref every render, breaks memo. Hoist outside component or wrap in useMemo.