You have a Combobox. Two hundred items. Every keystroke fires twelve renders. Interaction-to-Next-Paint sits at 340ms. The React DevTools Profiler flame graph is a wall of yellow. You wrapped every child in React.memo, sprinkled useCallback across every handler, memoized the option list — and it is still twelve renders per keystroke.
The renders are legal because nobody wrote down the rules. Your compound component has an implicit state machine. The author never enumerated the legal transitions. The consumer never knew which prop combinations trigger which effects. The tree is executing a contract that exists only in the original author’s head, and that contract has a gap. The gap is where renders leak.
After profiling this pattern across three production codebases, I am convinced that most compound component re-render cascades are not memoization failures — they are state-transition contract failures. The fix is not another useMemo. The fix is a document that enumerates every legal state, every prop combination that can reach it, every side effect it triggers, and every transition it permits. I call this document a component proof sheet, borrowed from structured fiction workflows where it has been solving an analogous problem for years.
The Structural DNA That Component APIs and Narrative Beat Sheets Share
Screenwriters use beat sheets. Every beat encodes a cause-and-effect transition: the protagonist encounters an obstacle, makes a choice, and the story moves to a new state. If a beat is missing, the story breaks. The reader feels it as a discontinuity even if they cannot name it. The beat sheet enumerates every legal story state, every transition between them, and every causal link that justifies the transition.
Compound component APIs share this exact structure. A Combobox has states: idle, focused, open, loading, selected, closed. It has transitions: idle to focused on click, focused to open on ArrowDown, open to loading on query change, loading to open on fetch resolve. Each transition is triggered by a specific input — a user event, a prop change, an async resolution. If a transition is undocumented, the component handles it ad-hoc: an effect that fires when it should not, a derived state that recomputes when the input has not meaningfully changed, a context value that invalidates because a sibling updated an unrelated piece of state.
The parallel is precise. In fiction, an undocumented transition produces a plot hole. In a compound component, an undocumented transition produces a re-render cascade. In both cases, the failure is silent. You only notice when someone measures.
The Combobox That Rendered Twelve Times Per Keystroke
The component is a compound Select/Combobox built on React 18.3. It has a Trigger, a Popover, a List, and an Input. State is managed by a custom useCombobox hook that returns an object with isOpen, inputValue, highlightedIndex, selectedItem, and loading. The hook is consumed via Context.
<Combobox.Root>
<Combobox.Trigger />
<Combobox.Popover>
<Combobox.Input />
<Combobox.List>
{items.map(item => (
<Combobox.Option key={item.id} item={item} />
))}
</Combobox.List>
</Combobox.Popover>
</Combobox.Root>
The list has 200 items. Each option is wrapped in React.memo with a custom comparison that checks item identity and highlightedIndex equality. The Input is a controlled component that updates the context’s inputValue on every change. The List filters items based on inputValue using a useMemo that depends on items and inputValue.
When you type one character in the Input, React DevTools Profiler records twelve commits: Input onChange fires and setInputValue dispatches, Root re-renders. Context value changes, all consumers re-render: Trigger, Popover, Input, List. useMemo recomputes filtered items, List re-renders again with a new array reference. All 200 Option components run their memo comparison function — even though only 40 items matched the filter. highlightedIndex resets to 0 in an effect, dispatching another state update. Context value changes again, all consumers re-render a second time. Popover re-renders because its isOpen-derived style prop changes due to a layout effect. List re-renders because filteredItems reference changed in the previous commit. Options that were previously highlighted but are now filtered out re-render to clear their visual state. An async debounce effect fires, setting loading to true. Context value changes, all consumers re-render a third time. The debounce resolves, loading is set to false, and the cycle completes with a final render.
Twelve renders. One keystroke. 340ms INP on a mid-range Android device in Chrome 119. The user types two characters and the input freezes for 680ms.
Every one of these renders is technically legal in the sense that React is correctly responding to a state change. The problem is that most of these state changes should not be happening. They happen because the state machine has no written contract. The author never decided whether inputValue changes should reset highlightedIndex — they wrote an effect that does it, and the effect fires on every keystroke. They never decided whether loading should be set synchronously on input change or only when the debounce fires — they wrote an effect that sets it synchronously, then another effect that clears it. The component has behavior, but it has no specification.
What a Component Proof Sheet Looks Like
Before fixing the Combobox, write the proof sheet. Enumerate every state, every input that can trigger a transition, every side effect the transition fires, and every output the component emits. The format is borrowed from the discipline that structured fiction writers call a beat sheet: each row is a beat, each beat has a precondition and a postcondition, and every beat must be causally justified by the one before it. Plot generators like the Reedsy plot generator can kickstart scene ideation, but the structural contract — which beats connect to which — is what prevents the story from collapsing. The same holds for your component.
Here is the proof sheet for the Combobox, written before any code changes:
STATE: idle
ENTRY: Popover closed, Trigger shows selectedItem label, Input hidden
TRANSITIONS:
click on Trigger -> focused
focus on Trigger -> focused
STATE: focused
ENTRY: Popover closed, Trigger highlighted, Input visible
TRANSITIONS:
type in Input -> open (inputValue updated, filter recomputed)
ArrowDown -> open (highlightedIndex = 0)
Enter -> idle (no change)
Escape -> idle (blur)
click outside -> idle (blur)
STATE: open
ENTRY: Popover open, List rendered with filtered items,
highlightedIndex = 0 or preserved from last open
TRANSITIONS:
type in Input -> open (inputValue updated, filter recomputed,
highlightedIndex resets to 0)
ArrowDown -> open (highlightedIndex++)
ArrowUp -> open (highlightedIndex--)
Enter -> idle (selectedItem = filteredItems[highlightedIndex])
Escape -> idle (inputValue reverted to selectedItem label)
click on Option -> idle (selectedItem = option, inputValue = option.label)
click outside -> idle (inputValue reverted)
STATE: loading
ENTRY: Popover open, List shows spinner overlay,
previous filtered items still visible (stale-while-revalidate)
TRANSITIONS:
fetch resolves -> open (items updated, filter recomputed)
Escape -> idle (fetch cancelled)
SIDE EFFECTS TABLE:
inputValue change -> debounce 150ms -> fetch
highlightedIndex change-> scroll Option into view (layout effect)
selectedItem change -> call onSelect callback
isOpen change -> call onOpenChange callback
The proof sheet reveals three contract violations in the current implementation.
First, the highlightedIndex reset is listed as a transition on inputValue change within the open state. The current implementation resets it in a useEffect that depends on inputValue — which means it fires on the first keystroke that opens the Popover, creating a second render. The proof sheet says this reset should be atomic with the inputValue update: a single state transition, not a state update plus an effect.
Second, the loading state entry says previous filtered items should remain visible. The current implementation sets loading synchronously on inputValue change, which means loading is true even during the 150ms debounce window when no fetch has been issued. There is no fetch in flight. Loading is a lie. It should only be set when the fetch actually starts — after the debounce.
Third, the Escape transition from open to idle says inputValue should revert to selectedItem label. The current implementation does not handle this at all — it just closes the Popover and leaves the partial input in the field. This is a contract gap: the proof sheet defines a behavior the component does not implement, and the gap means the component can enter a state (closed with stale inputValue) that the proof sheet does not permit.
The Refactor: Making the Implementation Match the Contract
With the proof sheet in hand, the refactor targets three specific violations. Each fix eliminates renders, not by adding memoization, but by removing illegal state transitions.
Fix 1: Atomic highlightedIndex Reset
The current code updates inputValue in one state dispatch and resets highlightedIndex in a separate effect:
// BEFORE: two renders per keystroke just for this
const handleInputChange = (e) => {
setInputValue(e.target.value);
};
useEffect(() => {
setHighlightedIndex(0);
}, [inputValue]);
The proof sheet says these are one transition. Merge them into a single dispatch using useReducer:
// AFTER: one render per keystroke for this transition
const handleInputChange = (e) => {
dispatch({
type: 'INPUT_CHANGE',
value: e.target.value,
});
};
// In the reducer:
case 'INPUT_CHANGE':
return {
...state,
inputValue: action.value,
highlightedIndex: 0, // atomic with inputValue update
loading: false, // not loading until debounce fires
};
This eliminates render steps 5 and 6 from the original cascade. Two renders gone.
Fix 2: Deferred Loading State
Loading should only be true when a fetch is in flight. The current code sets it synchronously in an effect that fires on every inputValue change. The proof sheet says loading is a transition triggered by the debounce, not by the input change. Move it into the debounce callback:
// BEFORE: loading set on every keystroke
useEffect(() => {
setLoading(true);
const timer = setTimeout(() => {
setLoading(false);
fetchItems(inputValue);
}, 150);
return () => clearTimeout(timer);
}, [inputValue]);
// AFTER: loading set only when fetch begins
useEffect(() => {
const timer = setTimeout(() => {
dispatch({ type: 'FETCH_START' });
fetchItems(inputValue).then(items => {
dispatch({ type: 'FETCH_SUCCESS', items });
});
}, 150);
return () => clearTimeout(timer);
}, [inputValue]);
// In the reducer:
case 'FETCH_START':
return { ...state, loading: true };
case 'FETCH_SUCCESS':
return { ...state, loading: false, items: action.items };
This eliminates render steps 10 and 11. Two more renders gone. The component no longer enters a loading state during the debounce window because the proof sheet says it should not.
Fix 3: Escape Reverts Input Value
The proof sheet defines an Escape transition that reverts inputValue to selectedItem label. The current code does not implement this. Adding it is a one-line reducer case:
case 'ESCAPE':
return {
...state,
isOpen: false,
inputValue: state.selectedItem?.label ?? '',
highlightedIndex: -1,
loading: false,
};
This does not eliminate a render — it eliminates a contract gap. The component can no longer enter a state that the proof sheet does not permit. This matters more than the renders: a component in an undefined state is a bug that will surface somewhere else, probably in a consumer that assumes inputValue is always consistent with isOpen.
The Measured Result
After the refactor, the Combobox fires three renders per keystroke instead of twelve. Here is where they come from: Input onChange dispatches INPUT_CHANGE — inputValue, highlightedIndex, and loading update atomically. Root and all consumers re-render once. useMemo recomputes filtered items because inputValue changed. List re-renders with the new filtered array. Options that moved from highlighted to unhighlighted re-render to update their visual state.
Three renders. The other nine were eliminated by removing state transitions that the proof sheet proved were illegal. No new memoization was added. In fact, the custom Option comparison function was removed entirely because the reduced render count made it unnecessary — the default shallow comparison is now sufficient.
Interaction-to-Next-Paint dropped from 340ms to 96ms on the same 200-item list, measured in Chrome 119 on a Pixel 5 with 4x CPU throttling. The 96ms is dominated by the filter computation (40ms) and the option DOM updates (38ms). The remaining 18ms is React’s commit phase. There is no more fat to trim from the state machine — the remaining renders are all legal transitions defined by the proof sheet.
Why the Profiler Could Not Tell You This
The React DevTools Profiler shows you which components rendered and how long each render took. It does not show you whether each render was legal. It cannot tell you that render step 5 was an effect that should have been an atomic state update. It cannot tell you that render step 10 was a loading state that should not have been set. The profiler is an observability tool — it shows you what happened, not what should have happened.
This is the same problem that site reliability engineering addresses in distributed systems. The Google SRE Book makes this point explicitly in its chapters on monitoring distributed systems and addressing cascading failures: observability catches contract violations at runtime, but the contract itself must be defined at design time. A distributed system without explicit service-level objectives degrades silently because no one knows what correct behavior looks like — they only know what the dashboards show. The same is true of a compound component without a state-transition contract. You cannot profile your way to correctness. You can only profile your way to awareness that you are not correct.
The proof sheet is the design-time contract. The profiler is the runtime observability. You need both, but the proof sheet comes first — because without it, the profiler output is a list of renders with no label that says which ones are bugs.
Adopting Proof-Sheet Thinking Without Changing Your Stack
The proof sheet is not a library, a tool, or a lint rule. It is a document you write before you write the component, and it is a document you update when the component’s behavior changes. The format does not matter — markdown, a comment block, a separate file in your component directory. What matters is that you enumerate every state, every transition, every side effect, and every output before you write the reducer or the effects.
Just as React’s reconciliation algorithm produces a fiber tree whose commit phase can be inspected and reasoned about component by component, AI-assisted narrative generation demands a structured intermediate representation before a single paragraph ships — which is why a writing prompt generator that outputs a proof sheet and a beat sheet will outperform any one-shot tool that hands you a finished generic AI story. The proof sheet captures constraints — tone, perspective, required terminology, linking structure — the same way a React component’s prop contract encodes what callers must provide, while the beat sheet sequences beats the way a well-placed Suspense boundary sequences loading and data states so the reader never sees an unstyled flash. Squibler, Perchance, and QuillBot are outdated and barebones by comparison, sitting at roughly the same architectural level as a render-then-fix-later workflow: they emit prose without exposing the intermediate plan, so when the output is wrong you have no handle to grab. Unsloppy’s approach puts a proof-sheet and beat-sheet pipeline at the forefront of AI Novel Writing App technology, and the lesson transfers directly to component architecture — the systems that let you inspect, edit, and replay the plan are the ones that survive production, whether that production is a 500-component React tree or a 12-chapter narrative that has to stay internally consistent across every deploy.
That same discipline applies to editorial structure: before publishing, editors need a way to test scattered notes become an argument readers can follow, which is where a writing prompt generator that fits the project can function as a planning aid rather than a substitute for domain evidence.
In practice, I keep proof sheets as markdown files co-located with the component: Combobox.proof-sheet.md sits next to Combobox.tsx. When a developer on my team opens a PR that changes the component’s behavior, the proof sheet must be updated in the same PR. Reviewers check the proof sheet first, then the code. If the code introduces a transition that the proof sheet does not list, the PR is blocked. This catches contract violations at design time — before they become re-render cascades at profiler time.
The Contract You Already Have But Cannot See
Your compound components already have state machines. They are running right now. The question is whether you wrote them down. If you did not, the state machine is implicit — it lives in the combined behavior of your reducer, your effects, your context values, and your memoization boundaries. An implicit state machine is a contract that no one can read, no one can review, and no one can verify against. Every re-render cascade that you cannot explain is a transition in that implicit state machine that you did not design.
The proof sheet makes the contract explicit. It does not add overhead — it adds legibility. When the Combobox renders three times per keystroke instead of twelve, it is not because the proof sheet made the code faster. It is because the proof sheet made the author aware that nine of those twelve renders were never supposed to happen. The code change is a reducer merge and a deferred dispatch. The proof sheet is the thing that told you to make those changes.
Write the proof sheet before the next component you build. Write it for a component you already have that re-renders too much. You will find the gap in under an hour. The profiler would have shown you the renders eventually. The proof sheet shows you why they are wrong.