React Render Optimization Patterns
React Render Optimization Patterns
The Symptom
The CMS editorial interface has a sidebar with a list of 200 articles and a main content area with a rich text editor. Typing in the editor causes visible lag. Each keystroke takes 80ms before the character appears on screen. The React Profiler shows that every keystroke re-renders the entire sidebar, including all 200 article list items.
The Cause
The editor state and the sidebar state live in the same component. When the editor state updates (user types), the parent re-renders, which re-renders the sidebar:
// SLOW: Editor state at same level as sidebar
function CMSLayout() {
const [articles] = useState<Article[]>(allArticles);
const [selectedId, setSelectedId] = useState<string>(articles[0].id);
const [editorContent, setEditorContent] = useState("");
return (
<div className="flex">
<Sidebar
articles={articles}
selectedId={selectedId}
onSelect={setSelectedId}
/>
<Editor content={editorContent} onChange={setEditorContent} />
</div>
);
}
Every setEditorContent call re-renders CMSLayout, which re-renders Sidebar with 200 ArticleListItem components. The sidebar has not changed, but React does not know that without explicit memoization or structural changes.
The Baseline
Keystroke latency in the CMS editor:
| Metric | Value |
|---|---|
| Keystroke to character visible | 80ms |
| Editor render time | 8ms |
| Sidebar render time | 68ms |
| Sidebar renders per keystroke | 1 (wasted) |
| INP (typing interaction) | 80ms |
The Fix
Pattern 1: Composition (Children as Props)
Move the state down to the component that owns it. The editor manages its own state. The sidebar manages its own state. The layout component does not re-render when either child updates:
// FAST: Composition - children don't re-render when siblings update
function CMSLayout({ children }: { children: ReactNode }) {
// No state here = no re-renders triggered by children
return <div className="flex">{children}</div>;
}
function SidebarContainer() {
const [articles] = useState<Article[]>(allArticles);
const [selectedId, setSelectedId] = useState<string>(articles[0].id);
return (
<Sidebar
articles={articles}
selectedId={selectedId}
onSelect={setSelectedId}
/>
);
}
function EditorContainer() {
const [content, setContent] = useState("");
return <Editor content={content} onChange={setContent} />;
}
// App.tsx
function App() {
return (
<CMSLayout>
<SidebarContainer />
<EditorContainer />
</CMSLayout>
);
}
Now EditorContainer owns content state. When the user types, only EditorContainer and its children re-render. SidebarContainer is a sibling, not a child of the state owner. React does not re-render siblings.
This pattern eliminates the re-render without React.memo, useMemo, or useCallback. No memoization API, no prop comparison cost, no risk of stale closures.
Pattern 2: Context Splitting
When components need to share state (the editor needs the selected article ID, the sidebar needs to set it), a shared context is common. But a single context with multiple values re-renders all consumers when any value changes:
// SLOW: Single context with editor and sidebar state
interface AppContextValue {
selectedArticleId: string;
setSelectedArticleId: (id: string) => void;
editorContent: string;
setEditorContent: (content: string) => void;
}
const AppContext = createContext<AppContextValue>(null!);
function CMSLayout() {
const [selectedId, setSelectedId] = useState("");
const [content, setContent] = useState("");
return (
<AppContext.Provider
value={{
selectedArticleId: selectedId,
setSelectedArticleId: setSelectedId,
editorContent: content,
setEditorContent: setContent,
}}
>
<Sidebar />
<Editor />
</AppContext.Provider>
);
}
// Every keystroke re-renders Sidebar because it consumes AppContext
// and editorContent changed
Split the context by update frequency:
// FAST: Separate contexts for different update frequencies
interface SelectionContextValue {
selectedArticleId: string;
setSelectedArticleId: (id: string) => void;
}
interface EditorContextValue {
content: string;
setContent: (content: string) => void;
}
const SelectionContext = createContext<SelectionContextValue>(null!);
const EditorContext = createContext<EditorContextValue>(null!);
function SelectionProvider({ children }: { children: ReactNode }) {
const [selectedId, setSelectedId] = useState("");
const value = useMemo(
() => ({
selectedArticleId: selectedId,
setSelectedArticleId: setSelectedId,
}),
[selectedId],
);
return (
<SelectionContext.Provider value={value}>
{children}
</SelectionContext.Provider>
);
}
function EditorProvider({ children }: { children: ReactNode }) {
const [content, setContent] = useState("");
const value = useMemo(() => ({ content, setContent }), [content]);
return (
<EditorContext.Provider value={value}>{children}</EditorContext.Provider>
);
}
// Sidebar only consumes SelectionContext - not affected by editor updates
function Sidebar() {
const { selectedArticleId, setSelectedArticleId } =
useContext(SelectionContext);
// ...
}
// Editor only consumes EditorContext - not affected by selection changes
function Editor() {
const { content, setContent } = useContext(EditorContext);
// ...
}
Now the sidebar consumes SelectionContext and the editor consumes EditorContext. Typing in the editor updates EditorContext only. The sidebar does not re-render because SelectionContext did not change.
Pattern 3: useDeferredValue for Search
The product listing page has a search filter. Typing “wire” filters 2,000 products to 43. Without deferral, each keystroke filters and re-renders the product grid synchronously:
// SLOW: Synchronous filtering on every keystroke
function ProductSearch() {
const [query, setQuery] = useState("");
const [products] = useState<Product[]>(allProducts);
const filtered = products.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase()),
);
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
<ProductGrid products={filtered} />
</>
);
}
// Typing "wireless" = 8 keystrokes × 45ms filter+render = 360ms of main thread blocking
useDeferredValue lets the input update immediately while the product grid update is deferred:
// FAST: Deferred filtering - input stays responsive
function ProductSearch() {
const [query, setQuery] = useState("");
const [products] = useState<Product[]>(allProducts);
const deferredQuery = useDeferredValue(query);
// Filter uses deferred value - does not block input
const filtered = useMemo(
() =>
products.filter((p) =>
p.name.toLowerCase().includes(deferredQuery.toLowerCase()),
),
[products, deferredQuery],
);
const isStale = query !== deferredQuery;
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
<div style={{ opacity: isStale ? 0.7 : 1, transition: "opacity 0.15s" }}>
<ProductGrid products={filtered} />
</div>
</>
);
}
The input updates on every keystroke with no delay. The product grid updates when React finds idle time, using the deferred query value. The isStale flag dims the grid slightly during filtering to signal that results are updating.
Keystroke latency with useDeferredValue: 4ms (input render only) instead of 45ms (input + filter + grid render).
The Proof
CMS editor after applying composition pattern:
| Metric | Before | After | Delta |
|---|---|---|---|
| Keystroke latency | 80ms | 8ms | -72ms |
| Sidebar re-renders per keystroke | 1 | 0 | -1 |
| INP (typing) | 80ms | 8ms | -72ms |
Product search after useDeferredValue:
| Metric | Before | After | Delta |
|---|---|---|---|
| Keystroke latency | 45ms | 4ms | -41ms |
| Grid updates per second | 22 | 8 (deferred) | -14 |
| INP (search typing) | 45ms | 4ms | -41ms |
The CI Lighthouse gate measures INP via Total Blocking Time simulation. The composition pattern and deferred updates reduce TBT by eliminating long tasks from unnecessary re-renders.
The Trade-off
The composition pattern requires restructuring the component tree. Existing components with co-located state must be split into containers (state owners) and presentational components. This is a refactoring cost that pays off only when the wasted re-renders cause measurable performance problems (>5ms render time for the unnecessarily re-rendered subtree).
useDeferredValue adds visual latency to the deferred content. The product grid updates 100-200ms after the last keystroke instead of synchronously. For search filtering, this is acceptable because users expect a brief delay. For real-time data displays (stock ticker, live dashboard), deferred updates introduce stale data visibility that may not be acceptable.
Context splitting increases the number of context providers in the component tree. Each provider is a React component with its own reconciliation cost. For the CMS editor with two contexts (selection and editor), this is negligible. For an application that splits contexts into 15 providers, the provider tree itself becomes a render cost. The guideline: split by update frequency, not by data domain. Group data that changes together into a single context.