React NotesRohit’s interview study guide
Chapter 10

Performance

What makes React re-render, how memo, useMemo, useCallback and the React Compiler skip work, how to measure before you optimise, and the browser-level techniques (virtualisation, code splitting, bundle size, debouncing, workers, idle time, layout) that keep big apps fast.

14 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Why components re-render

A render is React calling your component function to get fresh JSX. It is not the same as touching the DOM: after a render, React compares the new JSX with the previous one (reconciliation, see The Virtual DOM and reconciliation) and only changes the DOM nodes that actually differ. So a render is usually cheap, and "unnecessary re-renders" only matter when there are a lot of them or the components are slow.

A component renders for exactly these reasons:

  • Its own state changed (useState / useReducer set to a new value).
  • Its parent rendered. This is the default and the one that surprises people: when a component renders, React renders every child it returns, all the way down, whether or not their props changed.
  • A context it reads changed (useContext / use(SomeContext)), even if its parent didn't render.
  • It's the first render (mount), or it was remounted because its key or its position in the tree changed.

A good mental model is a waterfall: a state change starts at the component that owns the state and flows down through everything below it. Props don't start re-renders; they're carried along by the water. To stop the flow at some point you need an explicit "dam" — React.memo (or the React Compiler), which says "skip me if my props are the same as last time".

Parent.jsx
import { useState } from "react";

function Child() {
  console.log("Child rendered");
  return <p>I take no props</p>;
}

export function Parent() {
  const [count, setCount] = useState(0);
  console.log(`Parent rendered with ${count}`);
  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>Clicked {count}</button>
      <Child />
    </>
  );
}
// Mount, then two clicks — the logged order (from the test):
// Parent 0, Child, Parent 1, Child, Parent 2, Child
What’s happening
  1. On mount React calls Parent (count is 0), which returns a <button> and a <Child /> element, so React calls Child too: Parent 0, Child.
  2. The first click calls setCount((c) => c + 1). That schedules a re-render of the component that owns the state, Parent, with count 0 → 1.
  3. Parent runs again and returns a brand-new <Child /> element. Child isn't memoized, so React calls it again: Parent 1, Child. Child has no props at all — it re-renders purely because its parent did.
  4. React compares the Child output (<p>I take no props</p>) with the previous one, finds no difference and leaves that <p> in the DOM alone. Only the button's text node changes. The re-render cost was one function call, not a DOM update.
  5. The second click repeats it: Parent 2, Child. This is the waterfall: state changes flow down to every descendant unless something stops them.

Before reaching for memoisation, two structural fixes stop the waterfall for free. Move state down into the smallest component that needs it, so fewer components sit below it. Or pass the slow part in as children: elements created by a component's parent don't re-render when the component's own state changes.

ColourPicker.jsx
import { useState } from "react";

function SlowTree() {
  console.log("SlowTree rendered");
  return <p>slow</p>;
}

function ColourPicker({ children }) {
  const [colour, setColour] = useState("red");
  return (
    <div style={{ color: colour }}>
      <button onClick={() => setColour(colour === "red" ? "blue" : "red")}>{colour}</button>
      {children}
    </div>
  );
}

export function App() {
  return (
    <ColourPicker>
      <SlowTree />
    </ColourPicker>
  );
}
// Mount + two colour changes: "SlowTree rendered" is logged once (test: ["SlowTree"])
What’s happening
  1. App renders and creates the <SlowTree /> element. It hands it to ColourPicker as the children prop. The element object belongs to App's render.
  2. Clicking the button sets colour "red" → "blue", which re-renders ColourPicker only. App doesn't re-render, because its state didn't change.
  3. ColourPicker returns {children} — the same element object App created earlier, not a new one. When React sees the identical element in the same place, it knows nothing about it could have changed and skips SlowTree entirely.
  4. So SlowTree renders once on mount and never again, without any memo. The test logged ["SlowTree"] after two clicks.
  5. If you moved <SlowTree /> inside ColourPicker's JSX instead, every colour change would create a new element and SlowTree would render each time — exactly the Child case above.

Setting state to the same value (compared with Object.is) is a bail-out: React skips the re-render. In the test, a button that called setV("a") when v was already "a" produced no extra renders. (React's docs warn it may still call the component once before bailing out, so don't rely on it for correctness.)

AdvancedKeys remount; context gets through memo

Two more causes behave differently from a normal re-render.

KeyAndContext.jsx
import { createContext, memo, useContext, useState } from "react";

function Field({ label }) {
  const [text, setText] = useState("");
  return <input aria-label={label} value={text} onChange={(e) => setText(e.target.value)} />;
}

export function KeyDemo() {
  const [user, setUser] = useState("ann");
  return (
    <>
      <button onClick={() => setUser("bob")}>switch</button>
      <Field key={user} label={user} />
    </>
  );
}

const ThemeContext = createContext("light");

function Leaf() {
  const theme = useContext(ThemeContext);
  console.log(`Leaf ${theme}`);
  return <p>{theme}</p>;
}

const MemoMiddle = memo(function Middle() {
  console.log("Middle");
  return <Leaf />;
});

export function ThemeApp() {
  const [theme, setTheme] = useState("light");
  return (
    <ThemeContext value={theme}>
      <button onClick={() => setTheme("dark")}>dark</button>
      <MemoMiddle />
    </ThemeContext>
  );
}
// KeyDemo: type "hi", click switch → the "bob" input is empty
// ThemeApp: mount, click dark → Middle, Leaf light, Leaf dark
What’s happening
  1. In KeyDemo, typing "hi" renders Field with text "" → "h" → "hi" (logged as three renders of the ann field).
  2. Clicking "switch" changes key from "ann" to "bob". A different key means "a different component": React unmounts the old Field, throwing away its state, and mounts a new one. The test saw Field bob text="" — the "hi" is gone. That's a remount, not a re-render, and it's a deliberate tool for resetting state.
  3. In ThemeApp, clicking "dark" re-renders ThemeApp. MemoMiddle is memoized and has no props, so it's skipped — Middle is not logged again.
  4. But Leaf reads ThemeContext, and its value changed "light" → "dark". React finds every consumer of that context and re-renders it directly, jumping over the memoized parent: Leaf dark is logged.
  5. So memo stops the parent-render waterfall but never stops context or a component's own state. That's why big context values cause wide re-renders (see Context best practices and re-renders).
Does Child re-render?

Parent holds const [n, setN] = useState(0) and renders <Child label="hi" />. Child is a plain function component (no memo). You click a button that calls setN(n + 1). Does Child render again?

Show answer

Yes.

What’s happening
  1. setN(n + 1) changes Parent's state, so Parent renders.
  2. Rendering Parent creates a new <Child label="hi" /> element. React doesn't compare props for plain components — it simply calls Child again.
  3. The label prop is the same string, but that only matters to a memoized component. Wrap Child in memo and it would be skipped, because "hi" === "hi".
  4. Either way the DOM isn't touched, because Child's output is identical. "Re-render" means "function called", not "DOM updated".

#React.memo

memo(Component) returns a new component that remembers its last props and output. Before rendering, it compares the new props with the old ones, one by one, with Object.is (a shallow comparison). If every prop is the same, React reuses the last result and skips the component and everything below it. If any prop differs, it renders normally.

My notes call it a higher-order component, and that's the right shape: you pass it a component and get a component back. Think of it as a receptionist at the door who checks the props against last time's list and turns the visit away if nothing changed. The class-component equivalent is PureComponent (see PureComponent and shouldComponentUpdate).

Greeting.jsx
import { memo, useState } from "react";

const Greeting = memo(function Greeting({ name }) {
  console.log(`Greeting ${name}`);
  return <h3>Hello, {name}</h3>;
});

export function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Ann");
  return (
    <>
      <button onClick={() => setCount(count + 1)}>Count {count}</button>
      <button onClick={() => setName("Bob")}>Rename</button>
      <Greeting name={name} />
    </>
  );
}
// Mount, click Count twice, click Rename → logged: Greeting Ann, Greeting Bob
What’s happening
  1. On mount, Greeting renders with name "Ann" and logs Greeting Ann. The memo wrapper stores { name: "Ann" }.
  2. Clicking "Count" re-renders App (count 0 → 1) and creates <Greeting name="Ann" /> again. The wrapper compares Object.is("Ann", "Ann") → true for every prop, so React skips Greeting and reuses its last output. Nothing is logged.
  3. The second "Count" click is the same: still skipped.
  4. "Rename" sets name to "Bob". Now Object.is("Ann", "Bob") is false, so Greeting renders and logs Greeting Bob.
  5. The test logged exactly ["Greeting Ann", "Greeting Bob"]: two renders instead of four. Strings, numbers and booleans compare by value, so memo works with them out of the box.

The comparison is by reference for objects, arrays and functions. A literal written inline in JSX is a new object on every render, so it is never Object.is-equal to the last one, and memo does nothing:

Card.jsx
import { memo, useState } from "react";

const Card = memo(function Card({ style, onSelect }) {
  console.log("Card");
  return <div style={style} onClick={onSelect}>card</div>;
});

export function Broken() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(count + 1)}>Count {count}</button>
      <Card style={{ color: "red" }} onSelect={() => console.log("selected")} />
    </>
  );
}
// Mount + two clicks → "Card" logged 3 times: memo never skipped
What’s happening
  1. Each render of Broken evaluates { color: "red" } and () => console.log("selected") again, creating a new object and a new function.
  2. On the first click, memo compares style: the old object and the new one have the same contents but are different objects, so Object.is is false. One differing prop is enough: Card renders.
  3. The same thing happens on every click, so the test logged Card three times — exactly as if there were no memo, plus the cost of the comparison.
  4. The fixes: move constants out of the component (const red = { color: "red" } at module level), or keep them stable with useMemo / useCallback (next two topics), or let the React Compiler do it.
AdvancedA custom comparison function

memo takes an optional second argument, arePropsEqual(prevProps, nextProps). Return true to skip the render.

Chart.jsx
import { memo, useState } from "react";

const Chart = memo(
  function Chart({ points, title }) {
    console.log(`Chart ${title} ${points.length}`);
    return <p>{title}</p>;
  },
  (prev, next) => prev.points.length === next.points.length && prev.title === next.title,
);

export function ChartApp() {
  const [n, setN] = useState(0);
  return (
    <>
      <button onClick={() => setN(n + 1)}>tick</button>
      <Chart points={[1, 2, 3]} title="Sales" />
    </>
  );
}
// Mount + one tick → logged once: Chart Sales 3
What’s happening
  1. points={[1, 2, 3]} is a new array on every render, so the default shallow comparison would always say "changed".
  2. The custom function compares only the array's length and the title: 3 === 3 and "Sales" === "Sales" → true, so React skips the render on "tick".
  3. The danger is that it now ignores real changes: [1, 2, 3] → [7, 8, 9] has the same length and would be skipped, showing stale data. A comparator that forgets a prop (especially a function prop) causes exactly these "why didn't it update?" bugs.
  4. React's docs advise against deep-equality comparators too: they can be slower than just rendering, and they freeze the app if the data is large. Prefer stable props.

When to use it: a component that renders often with the same props and is noticeably expensive (a big table, a chart, a long list row). Don't wrap everything by hand: each memo adds a comparison per render and makes props discipline everyone's problem. With the React Compiler you rarely write it at all.

#useMemo

useMemo(calculate, deps) caches the result of a calculation between renders. On the first render it calls calculate() and stores the value. On later renders it compares each dependency with its previous value (again Object.is); if they're all the same, it returns the stored value without calling calculate again.

Mental model: a sticky note on the fridge with the answer and the inputs it was worked out from. As long as the inputs on the note match today's inputs, you read the answer off the note instead of redoing the sum. It has two jobs: skipping an expensive calculation, and keeping an object or array referentially stable so a memoized child or an effect doesn't see it as "new".

This is the example from my notes, with one change: the original loop ran count times (a handful of iterations), which is far too cheap to be worth memoizing, so here it runs count × 1,000,000 times.

ExpensiveCalculation.jsx
import { useState, useMemo } from "react";

const computeExpensiveValue = (num) => {
  console.log("Computing expensive value...");
  let total = 0;
  for (let i = 0; i < num * 1_000_000; i++) {
    total += i;
  }
  return total;
};

export default function ExpensiveCalculationComponent() {
  const [count, setCount] = useState(0);
  const [inputValue, setInputValue] = useState("");

  const memoizedValue = useMemo(() => computeExpensiveValue(count), [count]);

  return (
    <div>
      <h1>Count: {count}</h1>
      <h2>Computed Value: {memoizedValue}</h2>
      <input
        type="text"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="Type something..."
      />
      <button onClick={() => setCount(count + 1)}>Increment Count</button>
    </div>
  );
}
// Type "hello", then click Increment:
// "Computing expensive value..." logged 1 time after typing, 2 times after the click
// <h2> shows "Computed Value: 499999500000"
What’s happening
  1. Mount: count is 0, so useMemo calls computeExpensiveValue(0) (zero iterations), logs once, and stores 0 with the dependency list [0].
  2. Typing "hello" calls setInputValue five times, so the component renders five more times. On each one useMemo compares [count] — Object.is(0, 0) → same — and returns the stored 0. The function isn't called; the test still counted 1 log.
  3. Clicking "Increment Count" sets count 0 → 1. The dependency changed, so useMemo calls computeExpensiveValue(1): a million iterations, total 499999500000. Second log.
  4. Without useMemo (const memoizedValue = computeExpensiveValue(count)), every render runs the loop: the test counted 8 logs for mount + two clicks + five keystrokes. At count = 5 that's around 20 ms per keystroke on my machine — enough to make typing feel sticky.
  5. The point: the input re-renders the component on every keystroke, and useMemo makes those renders cheap again by remembering work that only depends on count.
AdvancedKeeping an object stable for an effect

An object created in the component body is new on every render. If it's an effect dependency, the effect runs after every render even though nothing meaningful changed:

SearchPage.jsx
import { useEffect, useMemo, useState } from "react";

function Search({ options }) {
  useEffect(() => {
    console.log("searching with", options);
  }, [options]);
  return null;
}

export function SearchPage() {
  const [text, setText] = useState("");
  const [, force] = useState(0);

  // ❌ const options = { mode: "whole-word", text };   → new object every render
  const options = useMemo(() => ({ mode: "whole-word", text }), [text]); // ✅ same object until text changes

  return (
    <>
      <button onClick={() => force((n) => n + 1)}>rerender</button>
      <Search options={options} />
    </>
  );
}
// Mount + two unrelated re-renders: the effect ran 3 times with the ❌ line, 1 time with useMemo
What’s happening
  1. With the ❌ line, each render of SearchPage makes a new { mode, text }. React compares the effect's dependency Object.is(oldOptions, newOptions) → false, so the effect runs again: mount + 2 clicks = 3 runs in the test.
  2. With useMemo, the object is only rebuilt when text changes. The two "rerender" clicks return the same object, the dependency is unchanged, and the effect ran once.
  3. An even simpler fix when you control Search: depend on the primitives ([options.mode, options.text]) or build the object inside the effect. React's docs prefer that: fewer things to memoize.
  4. If this were a fetch, the ❌ version would fire a network request on every keystroke anywhere in the page — a classic real-world bug.

When to use it, per React's docs: the calculation is noticeably slow (measure with console.time; around 1 ms or more is worth it) and its inputs rarely change; or the value is passed to a memo component; or it's a dependency of another hook. Otherwise it's just noise. Under the React Compiler you mostly stop writing it.

#useCallback

useCallback(fn, deps) caches a function between renders. It's exactly useMemo(() => fn, deps): it returns the same function object until a dependency changes. My notes put it neatly: useMemo returns a memoized value, useCallback returns a memoized function.

Functions declared inside a component are recreated on every render. That's normally fine and cheap. It only matters when the function's identity is observed: by a memo child (a new function means "props changed"), or by an effect/other hook that lists it as a dependency. So useCallback on its own does nothing useful — it's always a partner to memo or a dependency array.

TodoList.jsx
import { memo, useCallback, useState } from "react";

const TodoItem = memo(function TodoItem({ todo, onToggle }) {
  console.log(`TodoItem ${todo.id}`);
  return (
    <li>
      <label>
        <input type="checkbox" checked={todo.done} onChange={() => onToggle(todo.id)} />
        {todo.title}
      </label>
    </li>
  );
});

export function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, title: "Write tests", done: false },
    { id: 2, title: "Ship it", done: false },
  ]);
  const [filter, setFilter] = useState("");

  const toggle = useCallback((id) => {
    setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
  }, []); // no dependencies: the updater function reads the latest todos

  return (
    <>
      <input aria-label="filter" value={filter} onChange={(e) => setFilter(e.target.value)} />
      <ul>
        {todos.map((t) => (
          <TodoItem key={t.id} todo={t} onToggle={toggle} />
        ))}
      </ul>
    </>
  );
}
// After mount, typing one character:      []                (without useCallback: TodoItem 1, TodoItem 2)
// Then ticking "Write tests":               ["TodoItem 1"]    (without useCallback: TodoItem 1, TodoItem 2)
What’s happening
  1. Typing in the filter changes filter, so TodoList renders. With useCallback(…, []), toggle is the same function as last time, and each todo object is unchanged, so both memoized TodoItems compare equal and are skipped. The test logged nothing.
  2. Without useCallback (a plain const toggle = (id) => …), toggle is a new function on every render. Both items see onToggle change and render: TodoItem 1, TodoItem 2 for one keystroke.
  3. Ticking "Write tests" runs toggle(1). The updater prev.map(...) returns a new array where item 1 is a new object ({ ...t, done: true }) and item 2 is the same object as before.
  4. So only TodoItem 1 sees a changed prop and renders; item 2 is skipped. Without useCallback, item 2 would render too because of the new onToggle.
  5. The empty dependency array is safe because the function never reads todos from the render's closure; the updater form setTodos((prev) => …) gets the latest value from React (see Functional updates and stale closures). If it read todos directly, [] would freeze it at the first render's list.

#The React Compiler

Added

The React Compiler is a build-time Babel plugin (babel-plugin-react-compiler, 1.0 since October 2025) that adds memoization to your components and hooks automatically. It reads your code, works out which values depend on which props and state, and rewrites the component so each JSX element, object and callback is only recreated when its own inputs change. In effect it applies useMemo, useCallback and memo-like skipping at a finer grain than you would by hand — including after early returns and inside conditionals, where hooks aren't allowed.

Mental model: the compiler is a meticulous colleague who adds useMemo to every line for you — and never forgets a dependency. React's docs now say: "For new code, we recommend relying on the compiler for memoization and using useMemo/useCallback where needed to achieve precise control." It works with React 17, 18 and 19 (best with 19, which ships the react/compiler-runtime it imports).

Here's what it does to a tiny component. This is the real output of babel-plugin-react-compiler 1.0.0 in the sandbox:

Greeting.jsx (input)
function Greeting({ name, onWave }) {
  const message = `Hello, ${name}!`;
  return <button onClick={() => onWave(name)}>{message}</button>;
}
Greeting.jsx (compiled)
import { c as _c } from "react/compiler-runtime";
function Greeting(t0) {
  const $ = _c(6);
  const {
    name,
    onWave
  } = t0;
  const message = `Hello, ${name}!`;
  let t1;
  if ($[0] !== name || $[1] !== onWave) {
    t1 = () => onWave(name);
    $[0] = name;
    $[1] = onWave;
    $[2] = t1;
  } else {
    t1 = $[2];
  }
  let t2;
  if ($[3] !== message || $[4] !== t1) {
    t2 = <button onClick={t1}>{message}</button>;
    $[3] = message;
    $[4] = t1;
    $[5] = t2;
  } else {
    t2 = $[5];
  }
  return t2;
}
What’s happening
  1. _c(6) gives the component a cache array $ with 6 slots that lives on the component instance, like a hook's state. This is the compiler's version of useMemo's storage.
  2. The inline arrow () => onWave(name) depends on name and onWave. The compiler stores both in $[0] and $[1]; if both are unchanged it reuses the old arrow from $[2]. That's exactly useCallback(() => onWave(name), [name, onWave]), written for you.
  3. The <button> element depends on message and the arrow t1. If neither changed, it returns the same element object from $[5].
  4. Returning the identical element is the key trick: when React sees the same element as last time, it skips that subtree, like the children example in Why components re-render. That's how compiled code gets memo-like skipping without memo.
  5. message itself is a cheap string, so the compiler doesn't bother caching it — it just recomputes it and uses it as a dependency.

The effect on a real tree, measured in the sandbox with the same TodoApp compiled and uncompiled (no memo, no useCallback, nothing hand-written):

TodoApp.jsx
import { useState } from "react";

function Checkbox({ checked, onChange }) {
  console.log("Checkbox");
  return <input type="checkbox" checked={checked} onChange={onChange} />;
}

function TodoItem({ todo, onToggle }) {
  console.log(`TodoItem ${todo.id}`);
  return (
    <li>
      <label>
        <Checkbox checked={todo.done} onChange={() => onToggle(todo.id)} />
        {todo.title}
      </label>
    </li>
  );
}

export function TodoApp() {
  const [todos, setTodos] = useState([
    { id: 1, title: "Write tests", done: false },
    { id: 2, title: "Ship it", done: false },
  ]);
  const [filter, setFilter] = useState("");

  const toggle = (id) =>
    setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));

  return (
    <>
      <input aria-label="filter" value={filter} onChange={(e) => setFilter(e.target.value)} />
      <ul>
        {todos.map((t) => (
          <TodoItem key={t.id} todo={t} onToggle={toggle} />
        ))}
      </ul>
    </>
  );
}
//                         without the compiler                         with React Compiler 1.0
// type one character:     TodoItem 1, Checkbox, TodoItem 2, Checkbox    (nothing)
// tick "Write tests":     TodoItem 1, Checkbox, TodoItem 2, Checkbox    TodoItem 1, Checkbox, TodoItem 2
What’s happening
  1. Typing changes filter. Uncompiled, the waterfall renders both items and both checkboxes. Compiled, TodoApp caches the whole <ul> (it depends only on todos and toggle, and toggle is cached because it only uses the stable setTodos). The cached <ul> is the same element, so React skips everything below it: zero logs.
  2. Ticking item 1 changes todos. Compiled TodoApp recomputes the todos.map(...), creating new <TodoItem> elements for both items, so both TodoItem functions run.
  3. Inside the compiled TodoItem 2, todo and onToggle are the same as last time, so its cached <li> comes back unchanged and React skips Checkbox for item 2. Item 1's todo changed, so its <li> and Checkbox are rebuilt.
  4. Compare with hand-written memo + useCallback in useCallback, which skipped TodoItem 2 entirely. The compiler memoizes inside each component, so TodoItem 2's body still ran (cheaply, one comparison), but nothing below it did.
  5. The compiler did this with no changes to the code. That's its selling point: the same or better results than careful manual memoization, without the dependency arrays and without the bugs from forgotten ones.

Setting it up (React's install guide; Vite 8 uses @vitejs/plugin-react 6, which dropped the old inline babel option):

Shell
npm install -D babel-plugin-react-compiler@latest @rolldown/plugin-babel
npm install -D eslint-plugin-react-hooks@latest   # its recommended-latest preset includes the compiler rules
vite.config.js
import { defineConfig } from "vite";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";

export default defineConfig({
  plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});

// @vitejs/plugin-react 5 and earlier:
// plugins: [react({ babel: { plugins: ["babel-plugin-react-compiler"] } })]
// Plain Babel: put "babel-plugin-react-compiler" FIRST in babel.config.js plugins.
// Next.js 16: reactCompiler: true in next.config (with babel-plugin-react-compiler installed).
What’s happening
  1. The compiler is a Babel plugin, so a Vite 8 project (which no longer uses Babel by default) needs @rolldown/plugin-babel to run it; reactCompilerPreset() is a ready-made preset that filters which files it touches.
  2. It must run before other transforms, because it analyses your original source. That's why the plain-Babel setup says "first".
  3. Check it worked in React DevTools: compiled components show a "Memo ✨" badge next to their name.
  4. I tested the compiler itself with a small custom Vite plugin calling Babel; the @rolldown/plugin-babel config above is from the official docs and wasn't run in the sandbox.
AdvancedWhen the compiler skips a component

The compiler only optimises code that follows the Rules of React (pure render, hooks at the top level, no mutation of props/state, no reading refs during render). When it finds a violation it doesn't fail the build — it leaves that one component uncompiled and moves on. This component writes a ref during render:

RenderCounter.jsx
import { useRef } from "react";

function RenderCounter({ label }) {
  const renders = useRef(0);
  renders.current += 1; // writes a ref during render
  return <p>{label} rendered {renders.current} times</p>;
}
// Compiler log: CompileError "Cannot access refs during render"
// Output: the function is emitted unchanged — no _c(), no caching
What’s happening
  1. The compiler logged CompileError with the reason "Cannot access refs during render" and the explanation that refs "should only be accessed outside of render, such as in event handlers or effects".
  2. It then returned the original function untouched. The app still works; that component just doesn't get automatic memoization.
  3. The ESLint rules from eslint-plugin-react-hooks report the same problems in your editor. Per React's docs, "When the ESLint rule reports an error, it means the compiler will skip optimizing that specific component or hook." Fixing the lint error is how you get the optimisation back.
  4. Not every violation is detectable: a component that called items.sort() (mutating a prop) was compiled in my test. The compiler trusts that you follow the rules where it can't prove otherwise, which is why bugs from impure code can appear after enabling it.
  5. Escape hatch: put "use no memo"; as the first line of a function to opt it out (verified: the output is unchanged). React's docs mean it as temporary, until the underlying problem is fixed.

#Measuring: Profiler and React DevTools

Optimise after measuring, not before. My notes describe two ways to measure, and both are still the main tools: the React DevTools Profiler (a browser extension, for interactive investigation) and the <Profiler> component (in code, for collecting numbers programmatically). Since React 19.2 there's a third: React Performance Tracks in the browser's own Performance panel.

Mental model: DevTools is a video camera you point at the app while you click around, then replay frame by frame. <Profiler> is a stopwatch you bolt onto one part of the tree that reports every time that part renders.

ProfiledCounter.jsx
import { Profiler, useState } from "react";

function onRender(id, phase, actualDuration, baseDuration, startTime, commitTime) {
  console.log(`${id} ${phase} actual=${actualDuration.toFixed(2)}ms base=${baseDuration.toFixed(2)}ms`);
}

function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>Clicked {n}</button>;
}

export function App() {
  return (
    <Profiler id="Counter" onRender={onRender}>
      <Counter />
    </Profiler>
  );
}
// Mount + one click (jsdom, development build):
// Counter mount actual=1.56ms base=0.29ms
// Counter update actual=0.21ms base=0.21ms
What’s happening
  1. <Profiler> needs two props: an id string (so you can tell several profilers apart) and an onRender callback.
  2. After React commits the wrapped tree, it calls onRender. On mount, phase is "mount"; after the click, "update" (a third value, "nested-update", marks an update that was scheduled during the commit phase, for example by a layout effect).
  3. actualDuration is the time spent rendering this subtree for this commit. baseDuration estimates how long a full re-render of the subtree would take with no memoization. If memo is working, actualDuration on updates is well below baseDuration.
  4. startTime and commitTime are timestamps; commitTime is shared by every profiler in the same commit, so you can group them.
  5. My notes used onRender={(id, phase, actualDuration) => …} — that's correct; the extra parameters are optional. In a real app you'd send these to analytics rather than log them. The numbers above come from jsdom and are only illustrative.

Using the DevTools Profiler (React Developer Tools extension, "Profiler" tab):

  1. Click Start profiling (the record button), do the slow interaction, click Stop.
  2. Each commit is a bar in the top-right chart; step through them. Many commits for one click usually means cascading state updates.
  3. The flame graph shows the component tree for the selected commit. A bar's width is how long that component and its children took when they last rendered; its colour is how long they took in this commit (yellow is slower, blue faster, grey means it didn't render at all in this commit). The ranked view lists the commit's components with the slowest at the top.
  4. Select a component to see "Why did this render?" — "Props changed: (onSelect)", "Hook 2 changed", "The parent component rendered". Turn it on in the Profiler settings: "Record why each component rendered while profiling".
  5. In the general settings, "Highlight updates when components render" flashes a border around everything that renders — a quick way to spot a waterfall.
AdvancedReact Performance Tracks (React 19.2+)

Recent React versions add custom tracks to the browser's Performance panel (Chrome DevTools, and other browsers with the extensibility API). Record a normal performance trace and you get:

  • A Scheduler track with four lanes — Blocking (synchronous updates, e.g. from typing), Transition (startTransition work), Suspense (fallbacks and reveals) and Idle — each split into Update, Render, Commit and Remaining Effects phases, plus markers for cascading updates (an update scheduled during render that throws work away).
  • A Components track: a flame graph of component render and effect durations on the same timeline as network requests, JavaScript and layout. In development builds, clicking a render shows which props changed.

They're on by default in development; in the profiling build only the Scheduler tracks are on unless the subtree is wrapped in <Profiler>. The advantage over the React DevTools profiler is that you see React's work next to everything else the browser did in that frame — a slow commit next to a long layout or a slow fetch.

What to look for, in order: a single slow component (fix the algorithm or memoize the result), many components rendering for one interaction (move state down, split context, memoize), and then non-React costs in the Performance panel (layout, large lists, big bundles) — the rest of this chapter.

#Virtualizing long lists

Virtualisation (or "windowing") means rendering only the rows that are visible, plus a few extra, instead of the whole list. A list of 10,000 rows becomes ~15 real DOM nodes that are re-used as you scroll. A tall empty container keeps the scrollbar the right size, and each visible row is absolutely positioned at index × rowHeight.

Mental model: a train window. The landscape (your data) is miles long, but you only ever see a few metres of it through the window; as the train moves, new scenery appears on one side and disappears on the other. Mounting 10,000 rows costs memory, layout time and render time even if nobody scrolls; windowing makes the cost proportional to the screen, not the data.

react-window 2.x (current: 2.3) replaced 1.x's FixedSizeList / VariableSizeList with a single List component that takes a row component and the props for it:

ContactList.jsx
import { List } from "react-window";

const contacts = Array.from({ length: 10_000 }, (_, i) => ({ id: i, name: `Contact ${i}` }));

function ContactRow({ index, style, ariaAttributes, contacts, onSelect }) {
  const contact = contacts[index];
  return (
    <div style={style} {...ariaAttributes} onClick={() => onSelect(contact.id)}>
      {contact.name}
    </div>
  );
}

export function ContactList({ onSelect }) {
  return (
    <List
      rowComponent={ContactRow}
      rowCount={contacts.length}
      rowHeight={35}
      rowProps={{ contacts, onSelect }}
      style={{ height: 350 }}
    />
  );
}
// In the DOM after mount: 13 rows (Contact 0 … Contact 12) out of 10,000
// After scrolling to 5000 × 35px: 16 rows (Contact 4997 … Contact 5012)
What’s happening
  1. List reads its height from style (350px) and divides by rowHeight (35px): 10 rows fit. It adds overscanCount extra rows (default 3) so fast scrolling doesn't show blank gaps — 13 rows on mount.
  2. For each visible index, List renders <ContactRow> with index, a style (position: absolute; transform: translateY(…px); height: 35px) and ariaAttributes (role="listitem", aria-posinset, aria-setsize="10000"), plus everything in rowProps. You must spread style onto the row, or every row would stack at the top.
  3. rowProps is how data and callbacks reach rows. In 2.x it's a plain object prop; List re-renders rows when its values change.
  4. The test scrolled the container to 5000 × 35px. List worked out that rows 5000–5009 are visible and rendered 4997–5012 (three of overscan each side). Contact 0 is no longer in the DOM at all.
  5. Screen readers still hear "item 5001 of 10000" thanks to the aria attributes, even though only 16 rows exist. That's a 2.x improvement; 1.x left accessibility to you.

The example in my notes is the react-window 1.x API, which you'll still see in many codebases:

ContactList.v1.jsx
// react-window 1.x (not installed in the sandbox; shown for comparison)
import { FixedSizeList as List } from "react-window";

const Row = ({ index, style, data }) => <div style={style}>{data[index].name}</div>;

<List height={150} width={300} itemCount={1000} itemSize={35} itemData={contacts}>
  {Row}
</List>;
What’s happening
  1. 1.x had separate components: FixedSizeList, VariableSizeList, FixedSizeGrid, VariableSizeGrid. 2.x has List and Grid, and rowHeight accepts a number, a function (index, rowProps) => number, or a dynamic-height helper.
  2. 1.x took height and width as required props; 2.x takes them from style/CSS and measures with a ResizeObserver when you don't give a fixed size.
  3. 1.x passed the row as children (a render function/component) and the data through itemData, arriving as data; 2.x uses rowComponent and rowProps, and the props arrive spread onto the row.
  4. Correcting my notes: the snippet there left out width, which 1.x requires for a vertical list. Imperative scrolling also changed: 1.x used a ref with scrollToItem(index); 2.x uses listRef (or the useListRef hook) with scrollToRow({ index, align }).
AdvancedVariable heights, grids and infinite loading
  • Different row heights: if you can compute them, pass a function: rowHeight={(index, { contacts }) => (contacts[index].note ? 70 : 35)}. If you can't know until render, useDynamicRowHeight({ defaultRowHeight: 50 }) measures rows after they render — less efficient, and scrollToRow becomes instant only.
  • Tables: Grid virtualises both directions with cellComponent, rowCount/columnCount and rowHeight/columnWidth.
  • Infinite scrolling: onRowsRendered={({ startIndex, stopIndex }) => …} tells you which rows are visible; when stopIndex nears the end, fetch the next page (with TanStack Query's useInfiniteQuery, see Infinite queries and infinite scrolling).
  • Alternatives: TanStack Virtual (headless: you render the markup, it gives you positions) and the CSS property content-visibility: auto, which lets the browser skip layout and paint for off-screen sections without removing them from the DOM — good for long articles, not for 100,000 rows.
  • React Virtualized, which my notes mention, is the older, bigger library by the same author; react-window is its lighter successor and the one to use.

#Code splitting with React.lazy and Suspense

By default a bundler produces one JavaScript file containing the whole app, and the user downloads all of it before seeing anything. Code splitting cuts the bundle into separate files ("chunks") at the points where you write a dynamic import(). Lazy loading is downloading a chunk only when it's needed. In React, React.lazy turns a dynamic import into a component, and <Suspense> says what to show while it downloads.

Mental model: a restaurant menu versus the whole kitchen. You hand the user the menu (the main bundle) immediately, and each dish (chunk) is prepared only when ordered. The app starts faster because the first download is smaller.

This is the example from my notes, run as-is:

LazyApp.jsx
import { useState, Suspense, lazy } from "react";

// Lazy load MyComponent: the import() runs the first time it renders
const MyComponent = lazy(() => import("./MyComponent.jsx"));

export default function App() {
  const [showComponent, setShowComponent] = useState(false);
  return (
    <div>
      <h1>Lazy Loading Example</h1>
      <button onClick={() => setShowComponent(true)}>Load Component</button>
      {showComponent && (
        <Suspense fallback={<div>Loading...</div>}>
          <MyComponent />
        </Suspense>
      )}
    </div>
  );
}
// MyComponent.jsx: export default function MyComponent() { return <p>I am lazily loaded!</p>; }
// Click → "Loading..." immediately → "I am lazily loaded!" once the chunk arrives
What’s happening
  1. At build time, the bundler sees import("./MyComponent.jsx") and puts that module (and anything only it imports) into its own chunk, e.g. MyComponent-a1b2c3.js. The main bundle no longer contains it.
  2. lazy(...) returns a placeholder component and does nothing yet — the arrow function isn't called at module load.
  3. The user clicks; showComponent becomes true, and React tries to render <MyComponent /> for the first time. Now lazy calls the loader, which starts downloading the chunk, and throws the pending promise — this is how a component suspends.
  4. The nearest <Suspense> catches it and shows its fallback, Loading.... The test confirmed the fallback was on screen right after the click.
  5. When the promise resolves, React reads the module's default export, caches it inside the lazy object, and renders it in place of the fallback. Later renders use the cached component instantly; the chunk is never downloaded twice.

Where to split: routes first (each page is a natural chunk; see Lazy-loaded routes), then heavy, rarely used pieces — a chart library, a rich-text editor, a settings modal, an admin panel.

AdvancedNamed exports, preloading and where to declare lazy
lazy-patterns.jsx
import { lazy } from "react";

// lazy() expects a module with a DEFAULT export. For a named export, map it:
const NamedChart = lazy(() => import("./MyComponent.jsx").then((m) => ({ default: m.NamedChart })));

// Forget that and React throws when it renders:
// "Element type is invalid. Received a promise that resolves to: undefined.
//  Lazy element type must resolve to a class or function."

// Preload before it's needed: start the download on hover, render on click
const loadSettings = () => import("./Settings.jsx");
const Settings = lazy(loadSettings);
// <button onMouseEnter={loadSettings} onClick={() => setOpen(true)}>Settings</button>
What’s happening
  1. lazy reads module.default. A module that only has export function NamedChart has no default, so the .then wraps it into { default: NamedChart }. The test rendered chart with this pattern.
  2. Without the mapping, the resolved value has no default, and React reports the "Element type is invalid … resolves to: undefined" error quoted above (captured from the test's error boundary).
  3. Preloading: calling loadSettings() on hover starts the download early. Bundlers cache module loads, so when lazy later calls the same import(), it gets the already-loading (or loaded) module and the fallback may never show.
  4. Always declare lazy at module top level. Declaring it inside a component creates a new lazy type on every render, so React unmounts and remounts it each time, losing state and re-showing the fallback.
  5. If a lazy component appears during a startTransition update, React keeps showing the old UI instead of the fallback (see useTransition) — useful for tab switches.

#When a lazy chunk fails to load

My notes end the performance section with a question and no answer: "What if lazy loading fails???". It's a good interview question, because it happens in production more often than people expect: flaky mobile networks, ad blockers, and — the big one — a new deployment that deleted the old chunks while a user still has the old page open. Their app asks for Reports-3f9a1c.js, which no longer exists.

What React does: the import() promise rejects, lazy stores the error, and the component throws it during render. A thrown render error goes to the nearest error boundary. With no boundary, React 19 reports it as uncaught and removes the whole root — the user sees a blank page. The fix is a boundary around lazy parts, and ideally a retry.

Mental model: Suspense handles "not yet", an error boundary handles "never". A lazy component needs both around it.

lazyRetry.jsx
import { Component, lazy } from "react";

export class ChunkErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  retry = () => {
    this.setState({ error: null });
    this.props.onRetry?.();
  };

  render() {
    if (this.state.error) {
      return (
        <div role="alert">
          <p>Couldn't load this part of the page.</p>
          <button onClick={this.retry}>Try again</button>
        </div>
      );
    }
    return this.props.children;
  }
}

// Retries the import a few times before giving up, waiting a little longer each time.
export function lazyWithRetry(load, { retries = 2, delayMs = 500 } = {}) {
  return lazy(async () => {
    for (let attempt = 0; ; attempt++) {
      try {
        return await load();
      } catch (error) {
        if (attempt >= retries) throw error;
        await new Promise((r) => setTimeout(r, delayMs * 2 ** attempt));
      }
    }
  });
}

// Usage:
// const Reports = lazyWithRetry(() => import("./Reports.jsx"));
// <ChunkErrorBoundary>
//   <Suspense fallback={<p>Loading...</p>}><Reports /></Suspense>
// </ChunkErrorBoundary>
What’s happening
  1. Without any boundary, the test's root went from <h1>Dashboard</h1><p>Loading...</p> to an empty string once the import rejected, and createRoot's onUncaughtError received "Failed to fetch dynamically imported module: /assets/Reports-3f9a1c.js". The dashboard heading disappeared too.
  2. With ChunkErrorBoundary around the Suspense, the rejection is caught: getDerivedStateFromError stores it and the boundary renders the alert instead of its children. The rest of the page stays up.
  3. lazyWithRetry handles the flaky-network case before the boundary ever sees it: the loader catches a failed import(), waits 500 ms, then 1000 ms, and tries again. In the test, one failure followed by success rendered the component and the boundary never appeared; three failures in a row (retries = 2 plus the first try) finally threw to the boundary.
  4. Error boundaries must still be class components (there is no hook equivalent); the react-error-boundary package wraps this for you with a resetErrorBoundary callback (see react-error-boundary).

For the stale deployment case, retrying won't help — the file is gone. The usual fix is to reload the page once so the browser fetches the new HTML and the new chunk names. Vite fires a vite:preloadError event for exactly this:

main.js
window.addEventListener("vite:preloadError", (event) => {
  event.preventDefault(); // we're handling it: don't throw
  if (!sessionStorage.getItem("reloaded-after-chunk-error")) {
    sessionStorage.setItem("reloaded-after-chunk-error", "1");
    window.location.reload(); // get the new index.html and new chunk names
  }
});
What’s happening
  1. Vite emits vite:preloadError when a dynamic import (or a chunk it preloads) fails; event.payload holds the original error.
  2. preventDefault() stops Vite rethrowing it. The page then reloads, picking up the latest index.html, which references chunks that exist.
  3. The sessionStorage flag prevents an infinite reload loop if the failure is something else (the user is offline, or the server is down). Clear it after a successful load if you want the guard to reset.
  4. Pair it with Cache-Control: no-cache on index.html (Vite's docs call this out), and keep old hashed assets on the CDN for a while after each deploy, which avoids most of these errors in the first place. This snippet wasn't run in the sandbox; it follows Vite's documented event.

#Bundle size: tree shaking, minification, compression

Three separate steps make the JavaScript you ship smaller, and it helps to keep them apart:

  • Tree shaking removes code you import but never use. The bundler starts from your entry, follows imports, marks what's actually referenced, and drops the rest ("shaking the tree" so dead leaves fall off). It needs ES modules (import/export), because their structure is static and analysable at build time; CommonJS require is dynamic and mostly can't be shaken.
  • Minification rewrites the code that's left to be shorter: removes whitespace and comments, renames local variables to one letter, simplifies expressions. Vite 8 does this with the Oxc minifier by default (build.minify: 'oxc').
  • Compression (gzip or Brotli) squeezes the bytes on the wire. The server or CDN does it per request (or ahead of time); the browser decompresses. Vite reports gzip sizes but doesn't serve your files.

Real numbers from Vite 8.3 in the sandbox. First, tree shaking a local module:

treeshake.js
// utils.js exports formatPrice AND slugify
import { formatPrice } from "./utils.js";

console.log(formatPrice(1999));

// Built (minified) output — slugify is gone, formatPrice was inlined and renamed:
// function e(e){return`$${(e/100).toFixed(2)}`}console.log(e(1999));
What’s happening
  1. utils.js exports two functions. The entry imports only formatPrice, and nothing anywhere imports slugify.
  2. Rolldown (Vite 8's bundler) follows the static import { formatPrice }, includes formatPrice, and never includes slugify — it's absent from the output.
  3. The minifier then renames formatPrice to e, its parameter cents to e, and strips all whitespace. The whole bundle is 0.06 kB.
  4. My notes' advice — "use ES6 modules for bundlers to identify unused code" — is exactly right; it works in Vite/Rolldown, esbuild and Rspack the same as in webpack.

Libraries are where it really matters. The same one-line debounce usage, four ways:

lodash-imports.js
import { debounce } from "lodash-es";         // ESM, named import       →  2.25 kB (gzip 1.06 kB)
import _ from "lodash-es";  _.debounce(…);    // ESM, default object     → 84.37 kB (gzip 30.18 kB)
import _ from "lodash";     _.debounce(…);    // CommonJS, whole library → 71.75 kB (gzip 26.25 kB)
import debounce from "lodash/debounce";       // CommonJS, one file      →  3.31 kB (gzip 1.45 kB)
What’s happening
  1. lodash-es is lodash published as ES modules, one module per function. A named import lets the bundler keep debounce and the few helpers it uses: 2.25 kB.
  2. Importing the default export of lodash-es gives you one object containing every function. The bundler can't prove which properties you'll read off _, so it keeps all of them: 84 kB, ~37× larger, for the same feature.
  3. Plain lodash is CommonJS, which can't be tree-shaken: 72 kB whatever you import from it.
  4. With CommonJS, the workaround is importing the single file (lodash/debounce): 3.3 kB. The point: "only import what you need" depends on how the library is published, not just on your import syntax. Check a library's real cost before adding it (Vite's build report, rollup-plugin-visualizer, or bundlephobia).

Minification and compression on the 84 kB build above, measured with Node's zlib:

whole.jsrawgzip -9Brotli
unminified (--minify false)517 kB103 kB75 kB
minified (Oxc, default)84 kB30 kB26 kB

Minification did most of the work here (lodash's source is full of JSDoc comments), and compression roughly divided what was left by three. They stack: always ship minified and compressed. Brotli usually beats gzip by 10–20% on JavaScript and every modern browser supports it; most CDNs (Cloudflare, Vercel, Netlify) serve Brotli automatically.

AdvancedThe sideEffects flag

A module can have side effects at import time — code that runs just because the file was loaded (registering a polyfill, setting a global, importing CSS). The bundler must keep such a module even if you use none of its exports, unless the package promises it has none. Tested with a tiny local package ui-kit whose chart.js sets window.chartRegistry = []:

sidefx.js
import { Button } from "ui-kit"; // ui-kit/index.js re-exports Button and Chart

document.body.innerHTML = Button("Save");

// Built WITHOUT "sideEffects" in ui-kit's package.json:
//   var Button = (label) => `<button>${label}</button>`;
//   window.chartRegistry = [];               ← chart.js's top-level code kept
//   document.body.innerHTML = Button("Save");
// Built WITH "sideEffects": false:
//   var Button = (label) => `<button>${label}</button>`;
//   document.body.innerHTML = Button("Save");
What’s happening
  1. We never import Chart, so Chart itself is dropped either way.
  2. But chart.js has a top-level statement. Without a promise from the package, the bundler must assume that statement matters, so it keeps window.chartRegistry = [].
  3. "sideEffects": false in the package's package.json tells the bundler that unused modules can be skipped entirely; chart.js disappears.
  4. If a package does have side-effect files, list them instead: "sideEffects": ["*.css", "./src/polyfills.js"] — otherwise the bundler would drop your CSS imports. This is why component libraries should set the field (see Building a reusable component library).

#Debouncing and throttling in components

Both limit how often a function runs when events fire rapidly, but they answer different questions:

  • Debounce: "run once things have stopped for N ms". Each new event resets the timer. Use it for search-as-you-type, autosave, validating a field after the user pauses, window-resize recalculation.
  • Throttle: "run at most once every N ms while things keep happening". Use it for scroll position, mouse move, drag, progress reporting.

Mental model: debounce is a lift door that waits until people stop walking in before it closes; throttle is a metronome that ticks at a steady rate no matter how fast people clap. My notes point at lodash for both (debounce, throttle); the hand-written versions and useDebounce/useThrottle hooks are in useDebounce and useThrottle. The React-specific part is keeping one debounced function alive across renders and cleaning it up.

SearchBox.jsx
import { useEffect, useMemo, useState } from "react";
import { debounce } from "lodash-es";

export function SearchBox({ onSearch }) {
  const [text, setText] = useState("");

  // One debounced function for the life of the component
  const debouncedSearch = useMemo(() => debounce((q) => onSearch(q), 300), [onSearch]);

  // Cancel a pending call if the component unmounts (or onSearch changes)
  useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]);

  function handleChange(e) {
    setText(e.target.value); // the input stays instant
    debouncedSearch(e.target.value); // the expensive work waits for a pause
  }

  return <input aria-label="search" value={text} onChange={handleChange} />;
}
// Test (fake timers): type "react" with 50 ms between keys
// right after typing: onSearch called 0 times; 300 ms later: [["react"]]
What’s happening
  1. useMemo creates the debounced wrapper once and keeps it while onSearch is the same. The wrapper holds its timer in a closure, so it must be the same wrapper on every keystroke for debouncing to work.
  2. Typing "r" calls setText("r") (the input updates immediately — it's controlled) and debouncedSearch("r"), which starts a 300 ms timer.
  3. 50 ms later "e" arrives: lodash clears the timer and starts a new one with "re". The same happens for "a", "c", "t". After the last key, 0 calls have happened.
  4. 300 ms after "t", the timer finally fires and calls onSearch("react") — once, with the final text. The test recorded exactly [["react"]].
  5. The effect's cleanup calls debouncedSearch.cancel() on unmount. The test unmounted with a call pending and advanced the clock 1 s: onSearch was never called, so there's no update to an unmounted tree or a stale request.

Throttling a window event has the same shape — create the throttled function inside the effect that subscribes, so subscribe, throttle and cleanup live together:

ScrollProgress.jsx
import { useEffect, useState } from "react";
import { throttle } from "lodash-es";

export function ScrollProgress() {
  const [y, setY] = useState(0);

  useEffect(() => {
    const onScroll = throttle(() => setY(window.scrollY), 100);
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      window.removeEventListener("scroll", onScroll);
      onScroll.cancel();
    };
  }, []);

  return <p>Scrolled {y}px</p>;
}
// Test: a scroll event every 20 ms, scrollY = 40, 80, 120, …, 400
// shown after each event: 40 40 40 40 200 200 200 200 200 400 (then 400)
What’s happening
  1. The first scroll event (scrollY 40) calls the throttled function, and lodash's default leading: true runs it immediately: the text shows 40px.
  2. Events 2–5 arrive at 20, 40, 60 and 80 ms. They're inside the 100 ms window, so lodash just remembers the latest call (scrollY 200 at 80 ms) — the text stays at 40px. React renders nothing during this time.
  3. At 100 ms the window ends and the default trailing: true call runs with the latest value: 200px.
  4. The next window repeats: events at 100–180 ms, and the trailing call at 200 ms shows 400px. Ten events produced three state updates, and the final position is never lost thanks to the trailing call.
  5. { passive: true } tells the browser the handler won't call preventDefault(), so scrolling never waits for JavaScript. The cleanup removes the listener and cancels a pending trailing call.

#Web Workers from React

JavaScript in the page runs on one main thread, which also handles rendering, input and layout. A calculation that takes 600 ms freezes the page for 600 ms: no typing, no scrolling, no spinners. A Web Worker is a separate thread with its own JavaScript environment. You send it data with postMessage, it does the heavy work, and sends the result back as a message. Workers have no DOM access and share no variables with the page — only copied (structured-cloned) messages or transferred buffers.

Mental model: a back office. The front desk (main thread) keeps serving customers and passes a work order through a hatch; the back office crunches it and passes the answer back. My notes flag this as "seem critical" — it's the right tool for parsing big files, image processing, crypto, search indexing, and heavy maths. The React part is starting the worker in an effect, listening for the reply, and terminating it in the cleanup (which my notes already did).

primes.worker.js
import { countPrimes } from "./primes.js"; // a plain function: loops over numbers, counts primes

self.onmessage = (event) => {
  const result = countPrimes(event.data);
  self.postMessage(result);
};
PrimeCounter.jsx
import { useEffect, useState } from "react";

export function PrimeCounter({ limit }) {
  const [result, setResult] = useState(null);

  useEffect(() => {
    const worker = new Worker(new URL("./primes.worker.js", import.meta.url), { type: "module" });

    worker.onmessage = (event) => setResult(event.data); // listen first…
    worker.onerror = (event) => console.error("worker failed:", event.message);
    worker.postMessage(limit); // …then send the work

    return () => worker.terminate(); // stop the thread on unmount or when limit changes
  }, [limit]);

  return <p>{result === null ? "Counting…" : `${result} primes up to ${limit}`}</p>;
}
// Test (fake Worker running the same countPrimes):
// "Counting…" → "9592 primes up to 100000"; rerender with limit 10 → "4 primes up to 10"
// first worker terminated when limit changed; second terminated on unmount
What’s happening
  1. On mount, the effect creates a worker. new URL("./primes.worker.js", import.meta.url) is the pattern Vite (and webpack 5) recognise: at build time Vite bundles the worker file into its own chunk (the real build emitted assets/primes.worker-Bshm6BZM.js) and rewrites the URL. { type: "module" } makes it an ES-module worker so it can use import.
  2. The component renders "Counting…" right away; the main thread is free. In the worker thread, countPrimes(100000) runs, then postMessage(9592) sends the result back.
  3. The message arrives as an event on the main thread; onmessage calls setResult(9592) and React renders "9592 primes up to 100000".
  4. When limit changes to 10, React runs the cleanup first: worker.terminate() kills the old thread (even mid-calculation, so a stale answer can never arrive). The effect then starts a new worker for 10 → "4 primes up to 10".
  5. On unmount, the cleanup terminates the second worker. In Strict Mode development, the effect runs, cleans up and runs again, so you briefly create two workers — harmless because the first is terminated.
  6. For scale: countPrimes(5_000_000) took ~620 ms in Node 26 on my machine. On the main thread that's over half a second of frozen UI; in a worker the UI doesn't notice.
AdvancedMaking workers pleasant
  • Comlink (a tiny library from the Chrome team) wraps postMessage so you can await api.countPrimes(1e6) as if the worker's functions were local async functions.
  • Vite's ?worker import: import PrimesWorker from "./primes.worker.js?worker" then new PrimesWorker(). Same result, Vite-specific syntax.
  • Big data: messages are copied. For large ArrayBuffers, pass them in the transfer list (worker.postMessage(buf, [buf])) to move ownership instantly instead of copying.
  • Reuse one long-lived worker (created in a module or a context) when you send many jobs; creating a worker has a startup cost of a few milliseconds plus the script download.
  • Workers aren't free: if the job takes 5 ms, the message round-trip and cloning may cost more than it saves.

#requestIdleCallback for background work

requestIdleCallback(callback, { timeout }) asks the browser to run callback when the main thread has nothing better to do — after it has handled input, run animations and painted the frame. The callback receives a deadline object; deadline.timeRemaining() says how many milliseconds are left in this idle period (at most 50), so you can do a slice of work and schedule the next slice. My notes call it "cool" (and spell it requestIdCallback — the name is requestIdleCallback); the examples there didn't make it into the text, so here is one.

Mental model: doing the washing-up during the adverts. You only work in the gaps, stop when the programme comes back, and carry on in the next gap. Use it for non-urgent work: analytics batching, prefetching, warming caches, building a search index.

SearchIndex.jsx
import { useEffect, useState } from "react";
import { onIdle, cancelIdle } from "./idle.js"; // requestIdleCallback with a setTimeout fallback

// Builds a search index for `docs` in small slices while the browser is idle.
export function useIdleIndex(docs) {
  const [index, setIndex] = useState(null);

  useEffect(() => {
    const built = new Map();
    let i = 0;
    let handle;

    function work(deadline) {
      // Keep going while this idle period has time left (always do at least one item)
      do {
        const doc = docs[i++];
        for (const word of doc.text.toLowerCase().split(/\W+/)) {
          if (!built.has(word)) built.set(word, new Set());
          built.get(word).add(doc.id);
        }
      } while (i < docs.length && deadline.timeRemaining() > 1);

      if (i < docs.length) handle = onIdle(work, { timeout: 2000 });
      else setIndex(built); // done: one state update at the end
    }

    handle = onIdle(work, { timeout: 2000 });
    return () => cancelIdle(handle); // stop if docs change or we unmount
  }, [docs]);

  return index;
}

export function SearchReady({ docs }) {
  const index = useIdleIndex(docs);
  return <p>{index ? `Index ready: ${index.size} words` : "Indexing in the background…"}</p>;
}
// Test (3 docs, fake idle periods where timeRemaining() returns 5 then 0):
// "Indexing in the background…" → 2 idle periods → "Index ready: 8 words"
What’s happening
  1. After the first render ("Indexing in the background…"), the effect schedules work for the next idle period. Nothing runs yet; the page stays interactive.
  2. Idle period 1: work indexes doc 1, checks deadline.timeRemaining() → 5 ms (> 1), indexes doc 2, checks again → 0. It stops and schedules itself for the next idle period. i is now 2.
  3. Idle period 2: indexes doc 3; i < docs.length is false, so it calls setIndex(built) once. React renders "Index ready: 8 words" (react, renders, components, return, jsx, hooks, manage, state).
  4. The do … while guarantees progress (at least one doc per call) even if the deadline is already used up, for example when the timeout forced the callback to run on a busy thread.
  5. { timeout: 2000 } means "run within 2 s even if the browser never goes idle" — without it, a constantly busy page could starve the work forever. The cleanup cancels the scheduled callback; the test checked cancelIdleCallback received the handle on unmount.
idle.js
// requestIdleCallback with a fallback for browsers that don't have it (Safari)
export const onIdle =
  typeof window !== "undefined" && "requestIdleCallback" in window
    ? (cb, opts) => window.requestIdleCallback(cb, opts)
    : (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 0 }), 1);

export const cancelIdle =
  typeof window !== "undefined" && "cancelIdleCallback" in window
    ? (id) => window.cancelIdleCallback(id)
    : (id) => clearTimeout(id);
What’s happening
  1. Safari still doesn't enable requestIdleCallback by default (Chrome, Edge and Firefox support it), and jsdom doesn't have it either — the test printed requestIdleCallback in jsdom: false. So production code needs a fallback.
  2. The fallback runs the callback on a short timer with timeRemaining() returning 0. Thanks to the do … while, work still makes progress, one item per tick.
  3. The typeof window guard keeps the module safe to import during server rendering.
AdvancedWhy React itself doesn't use requestIdleCallback

Early concurrent-React experiments used requestIdleCallback, but it fires too rarely and inconsistently (it can wait a long time on busy pages and doesn't run at all in background tabs), so React's scheduler uses its own MessageChannel-based loop that yields every few milliseconds. The modern browser API for "let other things run" is scheduler.yield() (Chrome/Edge 129+, Firefox 142+, not Safari) and scheduler.postTask(fn, { priority: "background" }). Inside React, prefer startTransition / useDeferredValue to make rendering work interruptible; keep idle callbacks for non-React background work.

#Avoiding layout thrashing

When something on the page changes, the browser works through a pipeline: style (which CSS rules apply) → layout (also called reflow: the size and position of every box) → paint (filling in pixels) → composite (stacking layers on the GPU). Layout is the expensive step on large pages. Normally the browser batches it: you change ten things, and it does one layout before the next frame.

Layout thrashing is when your code forces the browser to do layout over and over within one frame. Reading a geometry property (offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle) after a write (changing a style or class) forces a synchronous layout right then, because the browser must give you an up-to-date answer. Alternate writes and reads in a loop and you get one full layout per iteration.

Mental model: asking an architect "how tall is the building now?" after every single brick. They have to re-measure the whole building each time. Lay all your bricks, then ask once.

EqualCards.jsx
import { useLayoutEffect, useRef } from "react";

// ❌ Thrashing: write, read, write, read… every read after a write forces a fresh layout
export function equaliseSlow(cards) {
  let max = 0;
  for (const el of cards) {
    el.style.height = ""; // write (invalidates layout)
    max = Math.max(max, el.offsetHeight); // read (forces layout now)
  }
  for (const el of cards) el.style.height = `${max}px`;
}

// ✅ Batched: all writes, then all reads (one layout), then all writes
export function equaliseFast(cards) {
  for (const el of cards) el.style.height = "";
  const max = Math.max(...cards.map((el) => el.offsetHeight));
  for (const el of cards) el.style.height = `${max}px`;
}

export function EqualCards({ items, equalise = equaliseFast }) {
  const listRef = useRef(null);

  // Before paint, so the user never sees the uneven heights
  useLayoutEffect(() => {
    equalise([...listRef.current.children]);
  }, [items, equalise]);

  return (
    <ul ref={listRef}>
      {items.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}
// Test (simulated layout, 3 cards of 20/60/40px): slow → 3 forced layouts, fast → 1; all cards end at 60px
What’s happening
  1. The goal: make every card as tall as the tallest. Each card first needs its natural height, so the code clears any old fixed height and measures.
  2. In equaliseSlow, card 1's style.height = "" invalidates layout; the very next line reads offsetHeight, so the browser must run layout now. Card 2 does the same: write, then forced layout. Three cards → three layouts in one frame; 300 cards → 300.
  3. equaliseFast does all the writes first (layout is invalidated once), then all the reads (the first read runs layout once; the rest reuse it), then all the writes (which the browser lays out once, at the end of the frame).
  4. useLayoutEffect runs after React has updated the DOM but before the browser paints, so the uneven heights never flash on screen (see useLayoutEffect). A plain useEffect could show one frame of jagged cards.
  5. Both versions end with every card at 60px. jsdom doesn't do layout, so the test simulated the browser rule: a style write marks layout dirty and the next offsetHeight read counts as a forced layout. It counted 3 for the slow version and 1 for the fast one. The real timing cost wasn't measured; in a browser it shows up as purple "Layout" blocks (with a "Forced reflow" warning) in the Performance panel.

My notes' tips are all right, with some React context:

  • Batch DOM updates — React already does this for its own DOM writes: everything from one render is applied in a single commit. Thrashing in React apps comes from your code in useLayoutEffect/refs, or from third-party widgets, that mixes reads and writes. Read first, write after, as above. For many elements, a single ResizeObserver gives you sizes without forcing layout.
  • Use transform instead of left/top for animation — moving with left changes layout every frame; transform: translateX(...) and opacity are handled by the compositor and skip layout and paint entirely. This is the biggest single win for smooth animations.
  • will-change — will-change: transform hints that an element will animate, so the browser can promote it to its own layer in advance. Correcting my notes slightly: use it sparingly, on the element about to animate (and remove it after), not on everything — each layer costs GPU memory, and blanket use makes things slower.
AdvancedRepaint vs reflow, and what triggers which
  • Changing geometry (width, height, margin, padding, top, font size, adding/removing elements, changing text) → layout + paint + composite.
  • Changing only appearance (color, background, box-shadow, visibility) → paint + composite, no layout (a repaint).
  • Changing transform or opacity on a composited layer → composite only.
  • Layout is usually scoped to what changed, but a change high in the tree (a class on <body>, a font loading) can relayout the whole page. CSS contain: layout (or content-visibility: auto) limits how far a change can spread.
  • Reading layout properties is free when nothing is dirty: reading offsetHeight ten times in a row after one write costs one layout, not ten. The cost is only in the write → read alternation.
Spot the forced layouts
JavaScript
for (const el of items) {
  el.style.width = `${container.offsetWidth / 2}px`;
}

How many forced layouts does this cause for 100 items, and how do you fix it?

Show answer

Up to 100 (one per iteration after the first), fixed by reading once before the loop.

What’s happening
  1. Iteration 1 reads container.offsetWidth — layout is clean (or gets computed once), then writes el.style.width, which invalidates layout.
  2. Iteration 2 reads container.offsetWidth again. Layout is dirty, so the browser must recompute it synchronously before answering. Then another write dirties it again.
  3. That repeats for every item: roughly 99 forced layouts in one frame, all to read a value that never changed.
  4. Fix: const half = container.offsetWidth / 2; before the loop, then only writes inside it — one layout, applied once at the end of the frame. (Or set width: 50% in CSS and skip JavaScript entirely.)

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.