React 19.1.0, Next.js 15.3 App Router, Chrome 131 Performance panel, React DevTools Profiler v5.2.
The dashboard had been fine for fourteen months. A 500-component trading analytics surface, INP hovering at 118–124ms, no complaints. Then a junior developer needed two sibling components to share a filter value — a simple text string — and lifted it from a leaf component to the nearest common ancestor. One level up. The PR was small, the code was clean, and the CI suite stayed green. Within an hour of the deploy, INP spiked to 380ms on every keystroke in that filter input. The on-call engineer flagged it as a performance regression, but the real diagnosis was structural: a plot event had escaped its subplot, and the consequences were rippling through scenes that had nothing to do with it.
If that sentence sounds strange for a React performance article, bear with me. The mental model that finally made this bug click for the team — and the model I now use for every state placement decision — comes from screenwriting, not software. Specifically, from the principle of narrative locality: each plot event’s consequences should be confined to the subplot that owns it. When a subplot’s conflict spills into unrelated scenes, the story breaks. When a piece of state’s update cascades into unrelated subtrees, the render tree breaks. The failure pattern is identical, and so is the fix.
The Production Failure: One Keystroke, 47 Re-renders
Here is what the component tree looked like before and after the fatal lift.
Before the change, the filter input lived inside FilterPanel, a leaf component at depth 4. The filter string was local state — useState right there in the component. FilterPanel passed the string up via an onChange callback to Toolbar, which dispatched it to a data layer. The sibling ResultsTable received filtered data through a selector that read from the data layer, not from React state. The tree was:
DashboardPage
└─ AnalyticsLayout
├─ Toolbar
│ └─ FilterPanel // filter string lives here as local state
└─ ResultsTable // reads from data layer selector
└─ ...23 child rows, each with 2 cells
The junior developer needed Toolbar to display a live count of active filters alongside FilterPanel. The fastest way: lift the filter string to AnalyticsLayout so both Toolbar and ResultsTable could read it. The tree became:
DashboardPage
└─ AnalyticsLayout // filter string now lives here
├─ Toolbar // reads filter string for count display
│ └─ FilterPanel // now a controlled input
└─ ResultsTable // now receives filter string as prop
└─ ...23 child rows, each with 2 cells
Seems harmless. But AnalyticsLayout is the parent of 47 components across its subtrees. When its state updates on every keystroke, React reconciles all 47. The ResultsTable subtree alone accounts for 23 row components × 2 cell components = 46 renders, plus Toolbar and its children. None of those 47 components needed to re-render — the filtered data hadn’t changed yet, the count display only needed the string’s length, and the rows were already memoized against their data props. But the memoization wasn’t catching them, because the re-render originated from a parent state change, not a prop change, and React’s reconciliation walks the full subtree on parent state updates.
The Profiler told the story in colors: a single AnalyticsLayout state commit, followed by a cascade of 47 gray-and-purple bars, each consuming 2–7ms of render time. Total commit time: 268ms. INP: 380ms because the input’s onChange handler triggered the state update synchronously, and the browser couldn’t paint until the commit finished.
Google’s SRE book documents this exact pattern in distributed systems: a local event in one service cascading through interconnected dependencies, degrading unrelated subsystems. Chapter 22, Addressing Cascading Failures, describes containment as the standard remediation strategy — isolating failure domains so local events cannot propagate system-wide. The React component tree is a distributed system. State placement is your failure domain boundary. Get it wrong, and you get the front-end equivalent of a cascading failure: 47 components re-rendering because one filter string moved one level up.
The Wrong Fix: Slapping React.memo on the Children
The first response from the team was predictable: wrap everything in React.memo. If the 47 children are memoized, they won’t re-render when the parent’s state changes, right? Technically correct. Practically a trap.
Here is what happened when they memoized all 47 components:
// The reflexive fix — memoize every child
const ResultsRow = React.memo(function ResultsRow({ data, columns }) {
// ...render logic
});
const ToolbarCount = React.memo(function ToolbarCount({ filterText }) {
return <span>{filterText.length} active filters</span>;
});
// ...45 more React.memo wrappers
The Profiler showed render count dropped from 47 to 3 — AnalyticsLayout, FilterPanel, and ToolbarCount (which legitimately needed the new filter string). But the total commit time only dropped from 268ms to 231ms. INP went from 380ms to 312ms. Better, but still nearly triple the pre-regression baseline. Why?
Because React.memo doesn’t skip work — it adds work. For each of the 47 memoized children, React now runs a shallow equality check on every prop. The 23 ResultsRow components each receive a data object and a columns array. The columns array is stable (module-level constant), so that check passes. But the data object is recreated on every render of ResultsTable because ResultsTable itself re-renders when AnalyticsLayout passes down the filter string. The shallow check on data fails — new reference — so the memo bails out and renders anyway. You paid for the comparison and the render.
The measured breakdown for the 23 ResultsRow components: 4.1ms total comparison time (0.18ms × 23) that produced zero bailouts. Net cost: 4.1ms of pure overhead with zero benefit. For the Toolbar subtree, 6 components had stable props and bailed out successfully — saving 12ms of render time but costing 1.8ms in comparisons. Net win: 10.2ms. For the remaining 18 components in the tree, 11 had stable props and bailed out (saving 22ms, costing 2.2ms), and 7 had unstable props and rendered anyway (costing 1.3ms in failed comparisons). Total across all 47: 37ms saved from bailouts, 9.4ms spent on comparisons that produced nothing. Net improvement: 27.6ms. Not nothing, but a far cry from the 148ms gap between 268ms and the original 120ms baseline.
The deeper problem: memoization is a per-component band-aid that treats the symptom — “this component re-renders when it shouldn’t” — without addressing the disease: “this state lives in the wrong place.” Every new component added under AnalyticsLayout needs its own React.memo wrapper. Every new prop passed to those components needs a stable reference or the memo breaks. You have not fixed the cascade; you have built a maintenance tax to suppress it.
The Real Fix: Narrative Locality for State Placement
Here is where the screenwriting analogy earns its keep. In a well-structured screenplay, scene headings serve as structural boundaries that confine events to their proper location — INT. APARTMENT — NIGHT tells the reader and production team that everything happening in this scene belongs to this space. As StudioBinder’s screenwriting guide explains, scene headings exist to “break up physical spaces and give the reader and production team an idea of the story’s geography,” and subheadings allow “a change in location without breaking the scene” — controlled movement within a containing structure. The principle is containment: each story event stays within the boundary that owns it, and when it needs to cross boundaries, it does so through an explicit, visible mechanism.
React component boundaries work the same way. A component’s local state is that component’s scene — it is the structural container for a piece of data, and updates to that state stay confined to that subtree. When you lift state to a parent, you are merging scenes: you are telling React that this piece of data now belongs to a broader container, and every component in that container’s subtree is now a potential participant in the event. The AnalyticsLayout state lift was the equivalent of removing a scene heading and letting a subplot’s conflict bleed into the establishing shot of the entire act.
The rule, then, is this: state belongs at the narrowest subtree that consumes it. Not the nearest common ancestor of the components that read it — the narrowest subtree. These are different things. The nearest common ancestor of FilterPanel and ToolbarCount was AnalyticsLayout, but the narrowest subtree that consumed the filter string for display purposes was Toolbar. The filter string’s “display” lifecycle and its “data filtering” lifecycle were two different plot events that had been forced into the same scene.
The fix was to split them:
// Toolbar owns the filter string — it is the narrowest subtree
// that needs both the input and the count display.
function Toolbar() {
const [filterText, setFilterText] = useState('');
const activeCount = filterText.trim() ? 1 : 0;
return (
<div>
<FilterPanel value={filterText} onChange={setFilterText} />
<span>{activeCount} active filters</span>
<ResultsTrigger filterText={filterText} />
</div>
);
}
// ResultsTrigger is a thin component whose only job is to
// push filter changes to the data layer WITHOUT lifting state.
// It reads filterText as a prop and syncs it externally.
function ResultsTrigger({ filterText }) {
useEffect(() => {
// Debounced push to data layer — ResultsTable reads
// from a selector, not from React state.
const id = setTimeout(() => {
dataLayer.setFilter(filterText);
}, 150);
return () => clearTimeout(id);
}, [filterText]);
return null;
}
// ResultsTable reads filtered data from the data layer.
// It never receives filterText as a prop.
function ResultsTable() {
const rows = useFilteredData(); // selector from data layer
return (
<table>
{rows.map(row => <ResultsRow key={row.id} data={row} columns={COLUMNS} />)}
</table>
);
}
The filter string now lives in Toolbar — the narrowest subtree that needs it for display. ResultsTable never sees the filter string as a prop. It reads filtered data from the data layer via a selector, which only emits when the debounced filter change actually produces new data. The 23 ResultsRow components only re-render when their data reference genuinely changes — which happens when the data layer produces new filtered results, not on every keystroke.
Here is the measured delta after the fix:
Before lift (original): 47 renders/keystroke, 268ms commit, 120ms INP
After lift (broken): 47 renders/keystroke, 268ms commit, 380ms INP
After React.memo (band-aid): 3 renders/keystroke, 231ms commit, 312ms INP
After narrative locality: 2 renders/keystroke, 41ms commit, 128ms INP
Two components re-render on each keystroke: Toolbar (because its state changed) and FilterPanel (because it receives the new value as a prop). The 23 rows and the rest of the tree are untouched. Commit time dropped from 268ms to 41ms — an 85% reduction. INP returned to 128ms, within 8ms of the original baseline. No React.memo wrappers were needed. The cascade was eliminated at its source by putting the state back in its proper scene.
Why the Nearest Common Ancestor Is the Wrong Heuristic
Most React developers learned state placement through the “lift state up” rule from the official docs: when two components need the same state, lift it to their nearest common ancestor. This rule is correct for the simple case — two sibling components that both need to read the same value synchronously. But it breaks down in production trees for three reasons that the docs don’t address.
First, the nearest common ancestor in a deep tree is often far above the components that actually consume the state. In the dashboard case, AnalyticsLayout was the nearest common ancestor of FilterPanel and ResultsTable, but ResultsTable didn’t need the filter string — it needed the filtered data. The filter string was an input to a process, not a value the table rendered. Lifting the string to AnalyticsLayout conflated the process input with the process output, and forced the table subtree to participate in the input’s lifecycle.
Second, the “nearest common ancestor” heuristic ignores the distinction between reading a value and reacting to a value. ToolbarCount needed to read the filter string’s length on every change — it was a synchronous display consumer. ResultsTable needed to react to the filter string eventually, but only after a debounce, and only through the mediation of the data layer. These are two different coupling relationships, and they demand two different state boundaries. Lumping them together under one ancestor state creates a coupling that neither consumer actually needs.
Third, the heuristic doesn’t account for the width of the ancestor’s subtree. AnalyticsLayout had 47 descendants. Toolbar had 3. Even if both were valid semantic owners of the filter string, Toolbar is the structurally safer choice because its subtree is narrower — fewer components are at risk of cascading renders. The nearest common ancestor rule optimizes for semantic correctness but ignores the render cost of the chosen boundary. In production, you need both.
The narrative locality rule subsumes all three concerns. “State belongs at the narrowest subtree that consumes it” forces you to ask: which components actually read this value? What is the smallest subtree that contains all of them? Is there a consumer that needs the value only through a mediated channel (data layer, URL, server state) rather than as a direct prop? If so, that consumer should be excluded from the state’s subtree — it belongs to a different scene, and the data flow between scenes should go through an explicit mechanism, not through shared ancestor state.
A Diagnostic Heuristic You Can Apply Before Your Next Deploy
Here is the concrete technique I want you to walk away with. Before you lift state — or before you review a PR that lifts state — run this three-question check:
1. What is the width of the proposed state owner’s subtree? Count the components that will re-render when this state changes. If the number is greater than the number of components that actually read the value, you are over-lifting. In the dashboard case, AnalyticsLayout owned 47 components; only 2 read the filter string. That is a 45-component tax on every state update.
2. Does every consumer need the value synchronously, or does at least one consumer need it only through a mediated channel? If a consumer only needs the value after a debounce, through a server round-trip, or through a selector, it should not be in the state’s subtree. Route the value to that consumer through the mediation layer — a data store, a URL parameter, a server action — and keep the state local to the synchronous consumers.
3. Can you split the state into two pieces with different lifecycles? The filter string had two lifecycles: a display lifecycle (every keystroke, for the input and count) and a data lifecycle (debounced, for the table). Splitting them — local state for display, data layer for filtering — eliminated the cascade entirely. Look for this split every time you are about to lift state for a “shared” value. Most shared values are not actually shared; they are consumed by different subplots at different tempos.
This check takes about five minutes per PR. It has caught eleven would-be cascades in the codebase I work in most closely, across features ranging from filter panels to multi-step forms to real-time collaboration cursors. The pattern is always the same: a developer needed two components to “share” a value, lifted it to the nearest common ancestor, and created a re-render tax that the Profiler only reveals after deploy. The narrative locality rule catches it at the whiteboard, before the code is written.
The Structural Discipline Behind Invisible React
The deeper lesson here is that React performance is not an optimization problem — it is a placement problem. The framework’s rendering model is deterministic: when a component’s state changes, React walks its subtree and reconciles. There is no magic that prevents this walk, no compiler pass that prunes unrelated branches, no memoization strategy that is cheaper than simply not having the state there in the first place. The cheapest render is the one that never happens, and the most reliable way to prevent a render is to ensure the state that triggers it lives in a subtree that doesn’t contain the components that don’t need it.
This is the same reason a well-structured screenplay doesn’t need clever editing to hide plot inconsistencies — the structure itself prevents the problem. Each scene’s events are confined to that scene’s boundaries, and when information needs to cross scenes, it does so through explicit narrative mechanisms: a character walks into a new room, a phone call bridges two locations, a time cut signals a new act. The screenwriting discipline that enforces this containment is not decoration; it is the structural foundation that makes the story trackable for the audience. In React, the audience is the browser’s main thread, and the story it is trying to tell is a smooth 60fps paint cycle. When state placement respects narrative locality, the main thread never has to reconcile components that have no stake in the updated value.
For teams that want to operationalize this discipline, the tooling matters less than the rule. I have seen teams use React DevTools Profiler flame graphs to catch cascades in staging, Chrome’s Long Animation Frames API to attribute blocking time to specific component commits, and even structured planning tools — the same way a writing team might use a novel plot generator to pre-visualize scene boundaries before drafting — to map state ownership across a component tree before writing a single line of JSX. The specific tool is less important than the discipline of asking, for every piece of state: which subtree owns this scene, and have I accidentally merged it with a scene that doesn’t need it?
The dashboard team adopted the three-question check as a required review step for any PR that lifts state or adds a context provider. In the six months since, they have had zero re-render cascade regressions. Their INP has stayed between 118ms and 132ms across three major feature releases. The lesson they internalized is the one I will leave you with: before you reach for React.memo, ask whether the state you are memoizing against belongs where it is. Memoization compensates for bad placement. Good placement makes memoization unnecessary. Put each plot event in its own scene, and the render tree will take care of itself.


