Skip to main content
fast frontend

React Render Optimization Patterns

7 min read Chapter 32 of 33

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:

MetricValue
Keystroke to character visible80ms
Editor render time8ms
Sidebar render time68ms
Sidebar renders per keystroke1 (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.

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:

MetricBeforeAfterDelta
Keystroke latency80ms8ms-72ms
Sidebar re-renders per keystroke10-1
INP (typing)80ms8ms-72ms

Product search after useDeferredValue:

MetricBeforeAfterDelta
Keystroke latency45ms4ms-41ms
Grid updates per second228 (deferred)-14
INP (search typing)45ms4ms-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.