# Performance Optimization — Part 2: Splitting, Virtualizing, and Deprioritizing

Continuation of Part 1. This part covers reducing *how much* work ships to the browser (code splitting) and *how much DOM* exists at once (virtualization), then the concurrent APIs that let React deprioritize work instead of doing less of it.

## 1. Code Splitting with `lazy` + `Suspense`

Every component imported at the top of a bundle ships in the initial JavaScript payload, whether or not it's ever rendered on first paint. `React.lazy` defers a component's module (and everything it imports) into a separate chunk, fetched only when first rendered:

```tsx
import { lazy, Suspense } from "react";

const SettingsPanel = lazy(() => import("./SettingsPanel"));

function App() {
  const [showSettings, setShowSettings] = useState(false);
  return (
    <>
      <button onClick={() => setShowSettings(true)}>Settings</button>
      {showSettings && (
        <Suspense fallback={<Spinner />}>
          <SettingsPanel />
        </Suspense>
      )}
    </>
  );
}
```

`import("./SettingsPanel")` is a dynamic import — bundlers (Webpack, Vite, Turbopack) treat it as a split-point boundary and emit a separate chunk file. `lazy` wraps the promise it returns so that rendering `<SettingsPanel />` before the chunk loads *throws the promise*, which the nearest `Suspense` boundary catches to show `fallback` until it resolves. This is the same throw-a-promise mechanism Server Components' Suspense integration uses (`frontend/ecosystem/03-nextjs_server_components`), just triggered by a network fetch for JS instead of data.

## 2. Route-Level and Component-Level Splitting

The highest-value split points are ones a user doesn't visit every session — a settings modal, an admin-only route, a rich text editor. Splitting a component that renders unconditionally near the top of the tree provides no benefit; it downloads immediately anyway, just as a separate network request instead of inline in the main bundle (and pays extra round-trip latency for nothing).

## 3. List Virtualization

Rendering 10,000 DOM nodes for a 10,000-row list is slow regardless of how well-memoized each row is — the browser still has to lay out and paint every one. Virtualization renders only the rows currently visible in the scroll viewport (plus a small overscan buffer), keeping the DOM node count constant regardless of list length:

```tsx
function VirtualList({ items, rowHeight, viewportHeight }: { items: Item[]; rowHeight: number; viewportHeight: number }) {
  const [scrollTop, setScrollTop] = useState(0);
  const startIndex = Math.floor(scrollTop / rowHeight);
  const visibleCount = Math.ceil(viewportHeight / rowHeight) + 1; // +1 overscan row
  const endIndex = Math.min(items.length, startIndex + visibleCount);

  return (
    <div style={{ height: viewportHeight, overflow: "auto" }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
      <div style={{ height: items.length * rowHeight, position: "relative" }}>
        {items.slice(startIndex, endIndex).map((item, i) => (
          <div key={item.id} style={{ position: "absolute", top: (startIndex + i) * rowHeight, height: rowHeight }}>
            {item.label}
          </div>
        ))}
      </div>
    </div>
  );
}
```

The outer scrollable `div` reports its real height (`items.length * rowHeight`) so the scrollbar behaves correctly, while only the rows within the viewport are ever mounted — an `onScroll` handler recomputes which slice is visible and React reconciles just that slice. Libraries like `@tanstack/react-virtual` and `react-window` implement this same core idea with variable-height support, horizontal lists, and better scroll performance than a naive `onScroll` handler.

## 4. `useTransition`: Marking Updates as Low Priority

`startTransition` tells React that a state update is not urgent — it can be interrupted by, and should yield to, more urgent updates like the keystroke that triggered it:

```tsx
function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Item[]>([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value); // urgent — the input must feel instant
    startTransition(() => {
      setResults(filterHugeList(e.target.value)); // expensive — can wait
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Updating…</span>}
      <ResultsList results={results} />
    </>
  );
}
```

Without `startTransition`, a single keystroke would synchronously run `filterHugeList` before the browser can paint the updated input value, making typing feel laggy on a large dataset. With it, the input update commits immediately in its own (default-priority) render pass, while the results update is processed in a low-priority lane that React works on in the background and can abandon mid-render if a newer keystroke arrives.

## 5. `useDeferredValue`: Deferring a Value Instead of a Setter

`useDeferredValue` achieves a similar effect without wrapping the state setter itself — useful when you don't own the code that calls `setState` (e.g., a value from a parent or a form library):

```tsx
function SearchPage({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  const results = useMemo(() => filterHugeList(deferredQuery), [deferredQuery]);
  const isStale = query !== deferredQuery;

  return (
    <div style={{ opacity: isStale ? 0.6 : 1 }}>
      <ResultsList results={results} />
    </div>
  );
}
```

`deferredQuery` initially equals `query`, but when `query` changes rapidly, React keeps rendering `deferredQuery` at its *previous* value in a low-priority background render, only updating it once things settle — comparing `query !== deferredQuery` is the standard way to show a stale-content indicator during that window.

## 6. Under the Hood: How a Transition Actually Yields

![A default-priority keystroke render interrupts an in-progress transition render; the transition resumes from scratch in a background lane after the urgent one commits](diagrams/02-transition-priority.svg)

`frontend/react/core/04-rendering_and_reconciliation` (Part 2) established that React's work loop checks `shouldYieldToBrowser()` after every fiber and that `lanes` is the priority bitmask driving what gets worked on next. `startTransition` and `useDeferredValue` are built directly on that mechanism:

- Code inside `startTransition`'s callback has its resulting updates tagged with the **Transition lane** instead of the default **Sync**/**InputContinuous** lane a plain `setState` gets.
- When React's scheduler has both a Transition-lane render in progress and a new Sync-lane update arrives (the next keystroke), it does not finish the low-priority work first — it **abandons the in-progress work-in-progress tree** for the transition, processes the urgent update to completion and commits it, and then **restarts** the transition's render from scratch, now starting from the newer committed state.
- This is why transition work must be a pure function of state, with no side effects that assume "runs to completion" — a partially-rendered, then-discarded work-in-progress tree is a normal, expected occurrence for transition-lane work, not a bug.
- `isPending` (from `useTransition`) and the `query !== deferredQuery` staleness check (from `useDeferredValue`) both work the same way under the hood: they read whether a lower-priority render tagged against that state is still outstanding.

None of this changes single-threaded JavaScript into multi-threaded work — it is cooperative scheduling within one thread, using the same time-slicing (`shouldYieldToBrowser`, roughly a 5ms budget) already described in the reconciliation topic. The gain is entirely about *ordering*: urgent work is allowed to cut in line ahead of expensive work that was already in flight.

## 7. Common Pitfalls

1. **Wrapping the urgent update itself in `startTransition`.** `startTransition(() => setQuery(value))` makes the input's own display lag, defeating the entire point — only the *expensive derived* update belongs inside the transition; the input's own state stays a normal `setState`.
2. **Virtualizing a short list.** Virtualization's bookkeeping (tracking `scrollTop`, computing visible ranges, absolute positioning) costs more than it saves below roughly a few hundred rows — profile first, same as `memo`.
3. **Splitting a component that always renders on first paint.** The network round-trip for that chunk delays the very content the split was supposed to speed up; only split what's conditional or below the fold.
4. **Expecting `useDeferredValue` to debounce.** It has no timer — it's tied to React's scheduling, not to elapsed time, so it does not skip intermediate values the way a `setTimeout`-based debounce does; it can still re-render on every keystroke if the machine is fast enough to keep up.
5. **Assuming `startTransition` makes the wrapped work faster.** It doesn't reduce the actual computation — `filterHugeList` still takes exactly as long; the benefit is entirely about not blocking urgent updates while it runs.
6. **Forgetting virtualized rows need a stable, item-identity `key`.** Reusing array index as `key` here interacts badly with absolute positioning during fast scrolling — reuse the guidance from `frontend/react/core/04-rendering_and_reconciliation` on `key` choice.

## 8. Quick Reference

| Problem | Tool |
|---|---|
| Large JS bundle, most of it unused on first paint | `React.lazy` + `Suspense`, split at route/rarely-used-component boundaries |
| Thousands of DOM nodes for a long list | Virtualization (render only the visible slice) |
| Typing feels laggy because of an expensive derived update | `useTransition` around the expensive `setState` |
| Expensive re-render driven by a prop/value you don't control the setter for | `useDeferredValue` |
| Need to know if a low-priority update is still pending | `isPending` (from `useTransition`) or `value !== deferredValue` |

| Mechanism | Fact |
|---|---|
| `lazy` | Dynamic `import()` becomes a bundler split point; throws a promise `Suspense` catches |
| Virtualization | DOM node count stays O(viewport), not O(list length) |
| Transition lane | A separate priority bitmask from default `setState`'s lane |
| Interruption | An urgent Sync-lane update discards and later restarts an in-flight Transition-lane render |
| Underlying primitive | Same cooperative time-slicing (`shouldYieldToBrowser`) as the base work loop |

This closes out `frontend/react/advanced`. `frontend/ecosystem` builds the surrounding toolchain — routing, styling, and state libraries — on top of these primitives.
