open atlas
↑ Back to track
React patterns, senior RXP · 15 · 02

Capstone II: the incremental refactor

Execute the part-1 inventory one change at a time: fix the P0 bugs first (derived-state effect, keys), then split context and stabilize its value, decompose via composition + custom hooks, and wrap async parts in Suspense + an Error Boundary — never a big-bang rewrite.

RXP Senior ◷ 21 min
Level
FoundationsJuniorMiddleSenior

Part 1 left you with a ranked inventory of the Dashboard feature: P0 effect-derived state, P0 index keys, P1 fat context, P1 god component, P2 prop drilling. The temptation now is to open the file, delete everything, and rewrite it “properly” in one sitting. That is the senior trap. A big-bang rewrite throws away the one thing the inventory bought you — a sequence of small, provable changes — and replaces it with a 2,000-line diff nobody can review and a feature that’s broken for a week.

The discipline is the opposite: one smell, one commit. Each step closes a named line item, keeps the app green, and is small enough to revert alone if it regresses. This lesson walks the inventory in impact order, fixing each pressure with exactly the pattern it asks for — and stopping there, so you don’t re-introduce the over-engineering you were hired to remove.

Goal

After this lesson you can take the part-1 inventory and execute it as an incremental refactor: fix the two P0 correctness bugs first (delete the derived-state effect, swap index keys for stable ids), then split the fat context and stabilize its value, decompose the god component via composition plus extracted custom hooks, colocate or lift state to the right level, and wrap the async parts in a <Suspense> boundary under an Error Boundary — each as a separate, green, revertible change, and each justified by the specific pressure it relieves rather than applied wholesale.

1

Fix the P0 bugs first, because correctness can’t wait behind a redesign — and each is a tiny diff. The derived-state effect ships stale data; the index keys corrupt per-row state. Neither needs the component split to fix, so do them before touching structure. Removing the effect also deletes a useState, a render-after-render cycle, and a dependency-array footgun in one move.

// before — P0: derived state via effect (stale by a frame, fragile by deps)
const [filtered, setFiltered] = useState<Row[]>([]);
useEffect(() => {
  setFiltered(data.filter((r) => r.name.includes(query)));
}, [data, query]);

// after — derive during render: no effect, no second state, never stale
const filtered = data.filter((r) => r.name.includes(query));
// before — P0: index key on a reorderable, stateful list
{rows.map((row, i) => <RowDetail key={i} row={row} />)}
// after — identity follows the row, not the slot
{rows.map((row) => <RowDetail key={row.id} row={row} />)}

Two commits, two closed P0 line items, zero new abstractions. The feature is now correct before it is pretty — and if anything regresses, the revert is one line.

2

Split the fat context and stabilize its value — but only the part that pressure justifies. The P1 fat context re-renders every consumer on every keystroke because query, selectedId, theme, and data ride one provider value. Split by update frequency, not by tidiness: the volatile query/selectedId go in one context, the stable theme/data in another, so a theme consumer no longer re-renders when you type. And wrap each value in useMemo so a new object literal every render doesn’t defeat the split.

// after — two contexts, split by volatility, each value stabilized
const FilterContext = createContext<FilterCtx | null>(null);
const DataContext = createContext<DataCtx | null>(null);

function DashboardProviders({ children }: { children: React.ReactNode }) {
  const [query, setQuery] = useState("");
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const filter = useMemo(() => ({ query, setQuery, selectedId, setSelectedId }),
    [query, selectedId]);              // volatile — changes per keystroke
  const data = useMemo(() => ({ rows, theme }), [rows, theme]); // stable
  return (
    <DataContext.Provider value={data}>
      <FilterContext.Provider value={filter}>{children}</FilterContext.Provider>
    </DataContext.Provider>
  );
}

Do not split into five contexts because you can — split until the high-frequency updates stop touching the low-frequency consumers. That’s where the pressure ends, so that’s where the change ends.

3

Decompose the god component with composition + extracted custom hooks — concern by concern, not in one rewrite. The P1 god component owns fetching, filtering, sorting, paginating, selection, and rendering. Pull one concern at a time: extract the data lifecycle into useDashboardData() (a custom hook), then carve the render into <Toolbar>, <DataTable>, and <Detail>. After each extraction the app still runs — you’re moving lines into a named home, not changing behaviour.

// extract the data concern into a hook (testable, reusable, one job)
function useDashboardData(query: string, sortKey: keyof Row) {
  const rows = use(fetchRows());            // React 19: read the promise
  return useMemo(
    () => sortRows(rows.filter((r) => r.name.includes(query)), sortKey),
    [rows, query, sortKey],
  );
}

// the god component shrinks to composition: each child owns one concern
function Dashboard() {
  return (
    <DashboardProviders>
      <Toolbar />
      <DataTable />
      <Detail />
    </DashboardProviders>
  );
}

Composition also dissolves the P2 prop drilling for free: once RowActions reads selectedId from FilterContext (or sits as a direct child of the component that owns it), the four pass-through hops vanish. You didn’t add a pattern for drilling — you let the structural fix subsume it.

4

Colocate or lift each piece of state to the level that owns it — then wrap the async edge in Suspense under an Error Boundary. With concerns separated, state finds its level: a row’s open/closed flag colocates inside <RowDetail> (nothing above it cares), while query/selectedId stay lifted to the provider because two siblings share them. State that nobody shares does not belong in the provider. Finally, the data fetch is the one async boundary, so it gets a <Suspense> fallback and an Error Boundary above it — co-located, because a boundary with no error UI is half a boundary.

// state at the right level + the async boundary made explicit
function DataTable() {
  return (
    <ErrorBoundary fallback={<TableError />}>
      <Suspense fallback={<TableSkeleton />}>
        <DataRows />          {/* useDashboardData() suspends in here */}
      </Suspense>
    </ErrorBoundary>
  );
}

function RowDetail({ row }: { row: Row }) {
  const [open, setOpen] = useState(false); // colocated — only this row cares
  return <div onClick={() => setOpen((o) => !o)}>{open && <Body row={row} />}</div>;
}

Five commits in, the inventory is closed: each P0 bug gone, the context split, the god component composed, state at its level, the async edge bounded — and the diff reads as a sequence, not an explosion.

Worked example

One step in full — turning the P0 effect-derived state into a green, isolated commit. This is the whole method in miniature: a single named smell, the smallest diff that closes it, and proof it’s gone.

// === commit: "fix(dashboard): derive filtered rows in render, drop the effect" ===
//
// before — P0 line item: effect-derived state, latent staleness bug
-  const [filtered, setFiltered] = useState<Row[]>([]);
-  useEffect(() => {
-    setFiltered(data.filter((r) => r.name.includes(query)));
-  }, [data, query]);                         // miss a dep → permanently stale
-  // ...render uses `filtered`
//
// after — derive during render: correct on the first frame, no extra state
+  const filtered = data.filter((r) => r.name.includes(query));
+  // ...render uses `filtered` unchanged

Why this is a complete step and not a half-measure: the change is purely local (the render below still reads filtered), it deletes more than it adds, and the “proof” writes itself — the inventory’s “P0, latent staleness bug” is now unobservable because there is no longer a frame where filtered disagrees with data + query. No memo needed yet; a .filter over a typical list is cheap, and adding useMemo here speculatively would be the exact over-engineering this capstone warns against. If profiling later shows this list is huge and the parent re-renders often, then useMemo earns its place — measured pressure, not pre-emptive ceremony.

Contrast the big-bang alternative: rewriting Dashboard end-to-end would bury this one-line correctness fix inside a 500-line diff, making it impossible to review the fix on its own, impossible to revert just this if the new structure regresses, and impossible to point a reviewer at “this commit fixed the staleness bug.” The incremental path keeps every claim falsifiable.

Why this works

Why fix the P0 bugs before the structural smells, when splitting the component feels like the “real” refactor? Because correctness ships to users and structure ships to developers. The derived-state effect can render wrong data today; the god component only makes tomorrow’s change slower. Doing the structure first also risks moving the staleness bug into a new file and shipping it “looking refactored” — the part-1 mistake. Order by impact, and the riskiest one-line fixes land first, while the diff is still small enough to scrutinize.

Common mistake

The failure mode this lesson exists to prevent has two faces. The first is the big-bang rewrite: deleting the feature and rebuilding it, which produces an unreviewable diff, a week of breakage, and no per-smell proof. The second is subtler — applying patterns where no pressure justified them: wrapping every derived value in useMemo, splitting context into five slivers, making a compound-component API for a one-off row, adding an Error Boundary around code that can’t throw. That re-introduces the over-engineering you were hired to remove. The rule both faces violate is the same: match each pattern to a named, ranked pressure from the inventory, and when the inventory has no line item, write no code.

Check yourself
Quiz

You're executing the part-1 inventory. Which sequencing is the senior move, and why?

Recap

An incremental refactor walks the part-1 inventory one change at a time, each a separate, green, revertible commit matched to a named pressure. Order by impact: the two P0 correctness bugs go first — delete the derived-state effect (derive during render) and swap index keys for stable ids — because they’re tiny diffs with the highest stakes and can’t wait behind a redesign. Then the P1 render smells: split the fat context by update frequency and useMemo its values so high-frequency updates stop touching low-frequency consumers, and decompose the god component via composition + extracted custom hooks, which dissolves the P2 prop drilling for free. With concerns separated, colocate or lift each piece of state to the level that owns it, and wrap the one async edge in <Suspense> under an Error Boundary. The two failure modes are mirror images: the big-bang rewrite (unreviewable, unprovable, broken for a week) and applying patterns with no pressure (re-introducing over-engineering). Both break the one rule — match each pattern to a ranked line item, and where the inventory is silent, write no code.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.