Skip to the solution
HardFrontend7 min read

React Compiler pitfalls — when does auto-memo bail out?

224 views

The problem

Question presented to candidate: "You enabled the React Compiler but the profiler shows components still re-rendering. What is going on?"

What a strong answer should cover:

  • The compiler bails out per component when it cannot prove the transformation is safe. That component is left exactly as written; the rest of the file is still compiled.
  • The failure mode is silence — you do not get an error, you get an unoptimised component. That is deliberate: it will never miscompile, only decline.
  • The common bail-out causes: mutating props or state, reading or writing a ref during render, other impure render behaviour, and code the analysis cannot follow.
  • The ESLint rule is the real adoption tool — it reports the violations, which is how you find out what was skipped.
  • Escape hatches: the "use no memo" directive to exclude a component deliberately, and opt-in mode to compile only annotated files.
  • Memoisation is a performance hint, not a semantic guarantee — code must still be correct if a value is recomputed.
  • It does not fix effect dependency arrays, refactor your state placement, or virtualise a list.
  • Existing manual useMemo and useCallback keep working; removal is a follow-up cleanup, not part of adoption.

Clarifying questions expected:

  • "Does the codebase pass the React Compiler ESLint rule?" — that answers the question directly.
  • "Is the compiler actually running on this file, or is it in opt-in mode?"

Code / implementation expected: Optional. Showing a component that would bail out, and its fixed version, is the useful form.

  • react-compiler
  • performance

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.

ReactA component that would bail out, and the same one made compilable
import { useState, useRef } from "react";

// ❌ WOULD BAIL OUT: mutates its props. The compiler cannot memoise around a
//    component that modifies its inputs, because the whole model assumes props
//    are read-only. In development React freezes props, so this also throws.
function MutatesProps({ config }) {
  const [report, setReport] = useState("not tried");
  const attempt = () => {
    try {
      config.touched = true;               // <- the bail-out cause
      setReport("mutation succeeded (and the compiler would skip this component)");
    } catch (e) {
      setReport("threw: " + e.message.slice(0, 60));
    }
  };
  return <Row label="❌ mutates props" onClick={attempt} note={report} />;
}

// ❌ WOULD BAIL OUT: reads a ref DURING RENDER. Render is then no longer a pure
//    function of props and state, so the output cannot be safely cached.
function ReadsRefInRender() {
  const counter = useRef(0);
  counter.current++;                        // <- impure: a write during render
  const impure = counter.current;            // <- and a read of it
  return <Row label="❌ ref during render" note={"render count read as " + impure} />;
}

// ✅ COMPILABLE: pure render, props untouched, ref only used in a handler.
function Compilable({ config }) {
  const [count, setCount] = useState(0);
  const clicks = useRef(0);
  // Derived during render from props and state only — no mutation, no ref read.
  const label = config.prefix + ": " + count;
  const bump = () => {
    clicks.current++;                        // refs in handlers are fine
    setCount((c) => c + 1);                  // immutable update
  };
  return <Row label="✅ pure" onClick={bump} note={label} />;
}

function Row({ label, note, onClick }) {
  return (
    <p style={{ margin: "6px 0", fontSize: 14 }}>
      <code style={{ display: "inline-block", minWidth: 180 }}>{label}</code>
      {onClick && <button onClick={onClick} style={{ marginRight: 8 }}>run</button>}
      <span style={{ color: "#555" }}>{note}</span>
    </p>
  );
}

export default function App() {
  const [config] = useState({ prefix: "count", touched: false });

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 560 }}>
      <MutatesProps config={config} />
      <ReadsRefInRender />
      <Compilable config={config} />

      <p style={{ color: "#666", fontSize: 13, marginTop: 12 }}>
        This playground has no React Compiler configured, so nothing here is
        actually compiled — the point is which components it <em>would</em>
        skip. Both failing cases violate React rules independently of the
        compiler, which is why the ESLint rule flags them either way.
      </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 113 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

How did ref handling change in React 19 (ref as a prop, forwardRef, ref cleanup)?

Continue