Skip to solution
hardFrontend

How do you optimize React component rendering?

929 views
01

Understand the problem

Question presented to candidate: "Forget how often a component renders. When one single render is slow, what do you do?"

What a strong answer should cover:

  • Distinguish the two axes immediately: how often a component renders and how much one render costs. They have different fixes.
  • Measure the second with the Profiler's actualDuration, or the Profiler component's onRender callback.
  • What makes a single render expensive: heavy computation in the render body, creating very large element trees, deep prop drilling causing wide subtree renders, and expensive derived data.
  • Move computation out of render: useMemo for derived values, or compute it once outside the component if it does not depend on props.
  • Reduce the tree: virtualise long lists — the largest single win available, since it changes the element count by orders of magnitude.
  • Split the component so the expensive part is isolated and can be skipped independently.
  • Defer rather than shrink: useTransition and useDeferredValue keep input responsive while an expensive render happens at lower priority.
  • Know the difference between actualDuration and baseDuration — the latter is what it would cost with no memoisation.
  • Development builds are substantially slower; profile a production-profiling build.

Clarifying questions expected:

  • "Is one render slow, or are there too many?" — that is the whole fork in this question.
  • "How large is the tree we are rendering?"

Code / implementation expected: Yes — a Profiler measuring an expensive render, and the fixes applied.

performanceoptimizationmemoizationreact.memo
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for senior React interviews — assumes profiling basics. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. The durations in section 3 were measured with React's own Profiler</c

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

Measuring render cost with Profiler, then cutting it three ways
Run Playground
import { useState, useRef, useMemo, useDeferredValue, Profiler } from "react";

// Module scope: computed ONCE when the module loads, not per render.
const ROWS = Array.from({ length: 3000 }, (_, i) => ({
  id: i,
  name: "Item " + i,
  score: (i * 7919) % 1000,
}));

function expensiveScore(row) {
  let x = 0;
  for (let i = 0; i < 400; i++) x += (row.score * i) % 13;
  return x;
}

// ❌ Everything computed inline, every row rendered.
function Naive({ query }) {
  const rows = ROWS
    .filter((r) => r.name.includes(query))
    .map((r) => ({ ...r, computed: expensiveScore(r) }))
    .sort((a, b) => a.computed - b.computed);
  return <Rows rows={rows} limit={rows.length} />;
}

// ✅ Memoised derivation + a windowed slice: far less work AND far fewer
//    elements. The element-count reduction is the bigger win of the two.
function Optimised({ query }) {
  const rows = useMemo(
    () =>
      ROWS.filter((r) => r.name.includes(query))
        .map((r) => ({ ...r, computed: expensiveScore(r) }))
        .sort((a, b) => a.computed - b.computed),
    [query],
  );
  return <Rows rows={rows} limit={30} />;
}

function Rows({ rows, limit }) {
  return (
    <div style={{ height: 120, overflow: "auto", border: "1px solid #ddd", borderRadius: 6, fontSize: 12 }}>
      {rows.slice(0, limit).map((r) => (
        <div key={r.id} style={{ padding: "1px 6px" }}>{r.name} — {r.computed}</div>
      ))}
      <div style={{ padding: "2px 6px", color: "#666" }}>
        rendering {Math.min(limit, rows.length)} of {rows.length}
      </div>
    </div>
  );
}

export default function App() {
  const [query, setQuery] = useState("");
  const [timings, setTimings] = useState({});
  // Same work, lower priority: typing stays responsive.
  const deferred = useDeferredValue(query);

  // Recording a timing into state causes another render, which fires onRender
  // again — an infinite loop unless you stop it. Record once per query value.
  const recorded = useRef({});
  const record = (id, phase, actualDuration) => {
    const key = id + ":" + deferred;
    if (recorded.current[key]) return;
    recorded.current[key] = true;
    setTimings((t) => ({ ...t, [id]: Math.round(actualDuration * 100) / 100 }));
  };

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 540 }}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="filter — try typing 1, 12, 123"
        style={{ width: "100%", marginBottom: 10 }}
      />

      <h4 style={{ margin: "0 0 4px" }}>
        ❌ naive — <span style={{ color: "#a33" }}>{timings.naive ?? "…"}ms</span> per render
      </h4>
      <Profiler id="naive" onRender={record}>
        <Naive query={deferred} />
      </Profiler>

      <h4 style={{ margin: "12px 0 4px" }}>
        ✅ memoised + windowed — <span style={{ color: "#161" }}>{timings.optimised ?? "…"}ms</span> per render
      </h4>
      <Profiler id="optimised" onRender={record}>
        <Optimised query={deferred} />
      </Profiler>

      <p style={{ color: "#666", fontSize: 13 }}>
        Both render exactly once per keystroke — the difference is entirely
        cost per render. Most of the saving comes from rendering 30 elements
        instead of 3000, not from the memoisation.
      </p>
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 78 of 119 decoded in the React.js track. One more won't hurt.

Back to track