Skip to the solution
HardFrontend7 min read

When does React.memo do nothing — or actually hurt performance?

404 views

The problem

Question presented to candidate: "A colleague has wrapped most components in React.memo and the profiler looks the same as before. Why?"

What a strong answer should cover:

  • memo compares props by reference (a shallow equality check). Any prop that is a new object, array, or function each render fails that check every time.
  • So the classic no-op is an inline style={{...}}, items={[...]}, or onClick={() => ...} in the parent's JSX — the comparison is guaranteed to fail.
  • When the comparison always fails, memo is pure overhead: you pay the check and still render.
  • memo also does nothing if the component re-renders for a different reason — its own state changed, or a context it consumes changed. It only blocks re-renders caused by the parent.
  • Wrapping cheap components is a net loss: the comparison plus the extra memory can exceed the render it saves.
  • A custom comparator can make it worse — deep-comparing a large object every render, or silently going stale if you forget a prop.
  • The reliable fixes are stable references (hoist constants, useCallback, useMemo) or not passing the prop at all (composition via children).

Clarifying questions expected:

  • "What props does this component receive, and are any of them created inline?"
  • "Is the parent re-rendering, or is it this component's own state or context?"

Code / implementation expected: Optional. Showing the inline-prop failure and its hoisted fix is the clearest form.

  • react
  • performance
  • memoization

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read · 10 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactThe same memo component with an inline prop and a hoisted one
import { useState, useRef, useEffect, useCallback, memo } from "react";

// Hoisted to module scope: ONE object for the life of the program, so the
// memo comparison can actually succeed.
const STABLE_STYLE = { padding: "2px 6px", background: "#eef" };

const Child = memo(function Child({ label, style, onRendered }) {
  // Counted in an effect, not during render — an effect with no dependency
  // array runs after every commit of THIS component, and a memoised component
  // that skips its render skips its effects too. onRendered writes to a ref on
  // the parent, so counting never schedules another render.
  useEffect(() => { onRendered(label); });

  return (
    <div style={style}>
      <code>{label}</code>
    </div>
  );
});

export default function App() {
  const [n, setN] = useState(0);
  const counts = useRef({});

  // Stable identity, so passing it down does not itself break the comparison.
  const onRendered = useCallback((label) => {
    counts.current[label] = (counts.current[label] || 0) + 1;
  }, []);

  const rows = ["inline props", "stable style only", "all stable"];

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.8, maxWidth: 560 }}>
      <button onClick={() => setN((v) => v + 1)}>
        re-render the parent ({n})
      </button>

      <div style={{ marginTop: 14, display: "grid", gap: 8 }}>
        {/* ❌ inline object created fresh on every parent render */}
        <Child
          label="inline props"
          style={{ padding: "2px 6px", background: "#fee" }}
          onRendered={onRendered}
        />

        {/* ❌ stable style, but a brand new inline arrow every render */}
        <Child
          label="stable style only"
          style={STABLE_STYLE}
          onRendered={(l) => onRendered(l)}
        />

        {/* ✅ every prop stable: the comparison succeeds and the child skips */}
        <Child
          label="all stable"
          style={STABLE_STYLE}
          onRendered={onRendered}
        />
      </div>

      <table style={{ marginTop: 14, fontSize: 13, borderCollapse: "collapse" }}>
        <tbody>
          {rows.map((r) => (
            <tr key={r}>
              <td style={{ paddingRight: 14 }}><code>{r}</code></td>
              <td><strong>{counts.current[r] || 0}</strong> renders</td>
            </tr>
          ))}
        </tbody>
      </table>

      <p style={{ fontSize: 13, color: "#666" }}>
        Click "re-render the parent" repeatedly. The first two counters climb
        with every click; the third stays at 1. All three are wrapped in the
        same <code>memo</code> — only the prop identities differ. The numbers
        are recorded during each commit and shown on the next one, so they
        trail the click count by one.
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 104 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

What are the resource preloading APIs in React 19 (`preload`, `preinit`, etc.)?

Continue