Capstone I: the pattern-smell inventory
Before refactoring a messy React feature, diagnose it: name each anti-pattern with the track's vocabulary, locate it, and rank by impact (wasted renders, latent bugs, change-cost) so every later edit can prove it improved something.
You inherit a Dashboard feature: one 500-line component, props threaded six levels deep, a “selected filter” that’s copied into state by an effect, a single context holding everything the feature touches, and a list keyed by array index. It works — until you change anything. Every tweak re-renders the whole tree, the filter occasionally shows stale data, and reordering the list scrambles which row is “open”.
The junior instinct is to start fixing — pull the effect, split the component, swap the keys. The senior instinct is to stop and inventory first. You cannot prove a refactor improved anything if you never wrote down what was wrong, where, and how much it cost. This capstone begins where every real refactor should: with a diagnosis, named in this track’s vocabulary, ranked by impact.
After this lesson you can take a messy React feature and produce a pattern-smell inventory: name each anti-pattern using this track’s terms (god component, prop drilling, effect-derived state, fat context, index keys), locate it by file and line-shape, and rank the smells by impact across three axes — wasted re-renders, latent bugs, and change-cost — so that each subsequent refactor step has a baseline it can be measured against.
Smell one — the god component: one component owning many unrelated concerns, so it has many reasons to change and one giant render. Scan for a single component that fetches, filters, sorts, paginates, holds selection, and renders. That’s not “a big component”, it’s a coupling magnet: any state change re-renders all of it, and any feature change risks all of it.
// smell: Dashboard owns 7 concerns and ~500 lines
function Dashboard() {
const [data, setData] = useState<Row[]>([]);
const [query, setQuery] = useState("");
const [sortKey, setSortKey] = useState<keyof Row>("name");
const [page, setPage] = useState(0);
const [selectedId, setSelectedId] = useState<string | null>(null);
// ...fetch effect, filter, sort, paginate, render table + toolbar + detail
}Locate it precisely: “concerns owned” is the metric. Seven here. The fix family is composition + custom hooks, but you do not apply it yet — you record it.
Smell two — prop drilling: passing data through components that don’t use it, just to reach a deep consumer. Follow one prop. If onSelect or currentUser is declared in Dashboard, accepted by Table, forwarded by TableBody, forwarded by Row, and only used in RowActions, every intermediate component is change-coupled to a prop it doesn’t care about — and re-renders when it changes.
// smell: onSelect threaded through 4 components that don't use it
<Table onSelect={onSelect}> {/* passes down */}
<TableBody onSelect={onSelect}> {/* passes down */}
<Row onSelect={onSelect}> {/* passes down */}
<RowActions onSelect={onSelect} /> {/* finally uses it */}The fix family is context or composition (lift markup so the consumer is a direct child). Record depth: four hops here. Drilling depth is your change-cost signal.
Smell three — effect-derived state: an effect that copies or computes one piece of state from another, creating a latent staleness bug. This is the highest-severity smell because it isn’t just slow, it’s wrong. An effect that writes filtered from data + query runs after render, so there’s a frame where filtered is stale, and any missed dependency makes it permanently stale.
// smell: 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]); // forget one dep → permanently staleThe fix is to derive during render (const filtered = data.filter(...)), no effect, no second state. Rank this near the top: it’s a correctness bug, not a performance one.
Smell four — fat context: one context holding many unrelated values, so every consumer re-renders when any single value changes. A DashboardContext that bundles query, selectedId, theme, and data means a component reading only theme re-renders on every keystroke that changes query. Context updates are all-or-nothing per provider value.
// smell: unrelated values in one context → keystroke re-renders theme consumers
const DashboardContext = createContext<{
query: string; selectedId: string | null; theme: Theme; data: Row[];
} | null>(null);The fix family is split contexts (or move volatile state out of context entirely). Record the consumer count and update frequency: that product is your wasted-render estimate.
Smell five — index keys on a reorderable list: key={i} ties React’s identity to position, not the item, so reordering or deleting corrupts per-row state. This is a latent bug, not just a lint warning: when rows carry local state (an open/closed detail, an input draft), index keys make that state follow the slot, not the row.
// smell: key is the index, but rows reorder and carry open/closed state
{rows.map((row, i) => <RowDetail key={i} row={row} />)} // delete row 0 → row 1's state shows on row 0The fix is a stable id key (key={row.id}). Severity depends on whether the list reorders and whether rows hold state — here both are true, so it’s a real bug, not a nit. Record that context, not just the line.
The inventory itself — the deliverable of this capstone part. Given the Dashboard feature above, you don’t write a single fix. You write this:
// pattern-smell inventory — Dashboard feature
// each row: smell · location · impact axis · severity · fix family
//
// P0 effect-derived state Dashboard L40-44 filtered = useEffect(data,query)
// impact: LATENT BUG — stale-by-a-frame + dep-fragility; can render wrong data
// fix: derive during render, delete the effect + the second useState
//
// P0 index keys (stateful) Table L120 rows.map((r,i)=><RowDetail key={i}/>)
// impact: LATENT BUG — list reorders AND rows hold open/closed state → corruption
// fix: key={row.id}
//
// P1 fat context DashboardContext L8 {query,selectedId,theme,data}
// impact: WASTED RENDERS — ~12 consumers re-render on every keystroke
// fix: split into per-concern contexts; move volatile state out
//
// P1 god component Dashboard L20-500 owns 7 concerns
// impact: WASTED RENDERS + change-cost — any state change re-renders all
// fix: composition (<Toolbar>/<DataTable>/<Detail>) + useDashboardData()
//
// P2 prop drilling onSelect Dashboard→Table→TableBody→Row→RowActions
// impact: CHANGE-COST — 4 hops change-coupled to a prop they don't use
// fix: context for the callback, or lift markup so consumer is a childNotice the ranking logic: correctness beats performance beats ergonomics. The two latent bugs (P0) go first because they can ship wrong data to a user — no amount of re-render savings matters if the output is incorrect. The two render smells (P1) come next, ordered by blast radius. Drilling (P2) is real but it only costs developer time, not user correctness, so it’s last. This ordering is what makes the next lessons measurable: when you fix the effect-derived state, you can point at “P0, latent staleness bug, gone” — proof, not vibes.
▸Why this works
Why inventory before editing, when you can see the fixes already? Because a refactor without a baseline can’t be defended in review or to yourself. “I split the component” invites “did anything actually get faster, or did you just move lines?” The inventory answers it: every fix maps to a named smell with a recorded impact, so each PR closes a specific, pre-stated line item. It also forces ranking, which stops you from spending your first hour on the satisfying-but-low-impact prop drilling while the staleness bug keeps shipping wrong data. Diagnosis is the part that makes the cure provable.
▸Common mistake
The failure mode this lesson exists to prevent: editing before inventorying. You spot the index keys, fix them in five minutes, feel productive — and now the diff is muddied, you’ve forgotten the other four smells, and you can’t show that anything systemic improved because there was never a “before” to compare to. Worse, fixing the cosmetic smells first (drilling, god-component splitting) often hides the correctness bugs by moving them, so they survive into production looking refactored. Write the whole ranked inventory first. Touch code second. The order is the discipline.
In the Dashboard inventory, why are effect-derived state and index keys ranked P0 — above fat context and the god component — even though those two cause far more re-renders?
A refactor begins with a diagnosis, not an edit. Given a messy React feature, produce a pattern-smell inventory: name each anti-pattern in this track’s vocabulary — god component (many concerns, wide renders), prop drilling (deep pass-through, change-cost), effect-derived state (latent staleness bug), fat context (keystroke re-renders across consumers), and index keys (reorder corrupts per-row state) — locate each by file and line-shape, and rank them across three impact axes: wasted re-renders, latent bugs, change-cost. The ranking rule is correctness beats performance beats ergonomics, so the two latent bugs sit at P0 above the render smells and the drilling. The deliverable of this capstone part is that ranked inventory, not a single fix — because the failure mode is editing before inventorying, which leaves you unable to prove anything improved. With the baseline written down, every later refactor step closes a named, pre-stated line item.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.