React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

re-rendersperformancereact-devtoolsprofilermemostate-colocationchildren-pattern

Preventing Unnecessary Re-renders — Identify and eliminate wasted renders for a faster UI

Recipe

// BEFORE: Every keystroke re-renders the entire product list (47 renders)
function ProductPage() {
  const [search, setSearch] = useState("");
  const [products] = useState<Product[]>(initialProducts);
 
  return (
    <div>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      <ProductList products={products} />  {/* Re-renders on every keystroke */}
      <Footer />                           {/* Also re-renders unnecessarily */}
    </div>
  );
}
 
// AFTER: Only SearchBar re-renders on keystroke (3 renders)
function ProductPage() {
  const [products] = useState<Product[]>(initialProducts);
 
  return (
    <div>
      <SearchBar />                        {/* State colocated here */}
      <ProductList products={products} />  {/* No longer re-renders */}
      <Footer />                           {/* No longer re-renders */}
    </div>
  );
}
 
function SearchBar() {
  const [search, setSearch] = useState("");
  return <input value={search} onChange={(e) => setSearch(e.target.value)} />;
}

When to reach for this: When React DevTools Profiler highlights show components flashing on every interaction, or when the UI feels sluggish during typing, scrolling, or tab switching. Always profile first to confirm the problem before applying fixes.

Working Example

// ---- BEFORE: Slow list with unnecessary re-renders ----
 
interface Task {
  id: string;
  title: string;
  completed: boolean;
}
 
// Problem: TaskItem re-renders for ALL items when any state changes
function TaskApp() {
  const [tasks, setTasks] = useState<Task[]>(generateTasks(500));
  const [filter, setFilter] = useState("all");
  const [newTitle, setNewTitle] = useState("");
 
  const toggleTask = (id: string) => {
    setTasks((prev) =>
      prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
  };
 
  const filtered = tasks.filter((t) => {
    if (filter === "done") return t.completed;
    if (filter === "todo") return !t.completed;
    return true;
  });
 
  return (
    <div>
      {/* Typing here re-renders all 500 TaskItems */}
      <input
        value={newTitle}
        onChange={(e) => setNewTitle(e.target.value)}
        placeholder="New task..."
      />
      <select value={filter} onChange={(e) => setFilter(e.target.value)}>
        <option value="all">All</option>
        <option value="done">Done</option>
        <option value="todo">Todo</option>
      </select>
      <ul>
        {filtered.map((task) => (
          <TaskItem
            key={task.id}
            task={task}
            onToggle={() => toggleTask(task.id)} // New function every render!
          />
        ))}
      </ul>
    </div>
  );
}
 
function TaskItem({ task, onToggle }: { task: Task; onToggle: () => void }) {
  // Simulated expensive render
  const start = performance.now();
  while (performance.now() - start < 1) {} // 1ms per item = 500ms total
  return (
    <li onClick={onToggle} style={{ textDecoration: task.completed ? "line-through" : "none" }}>
      {task.title}
    </li>
  );
}
 
// ---- AFTER: Optimized — reduces re-renders from 500 to ~1-3 per interaction ----
 
function TaskAppOptimized() {
  const [tasks, setTasks] = useState<Task[]>(generateTasks(500));
  const [filter, setFilter] = useState("all");
 
  const toggleTask = useCallback((id: string) => {
    setTasks((prev) =>
      prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
  }, []);
 
  const filtered = useMemo(
    () =>
      tasks.filter((t) => {
        if (filter === "done") return t.completed;
        if (filter === "todo") return !t.completed;
        return true;
      }),
    [tasks, filter]
  );
 
  return (
    <div>
      {/* State colocated — typing no longer re-renders the list */}
      <NewTaskInput
        onAdd={(title) =>
          setTasks((prev) => [...prev, { id: crypto.randomUUID(), title, completed: false }])
        }
      />
      <select value={filter} onChange={(e) => setFilter(e.target.value)}>
        <option value="all">All</option>
        <option value="done">Done</option>
        <option value="todo">Todo</option>
      </select>
      <ul>
        {filtered.map((task) => (
          <MemoizedTaskItem key={task.id} task={task} onToggle={toggleTask} />
        ))}
      </ul>
    </div>
  );
}
 
// Colocated input state — isolated from the list
function NewTaskInput({ onAdd }: { onAdd: (title: string) => void }) {
  const [title, setTitle] = useState("");
  return (
    <input
      value={title}
      onChange={(e) => setTitle(e.target.value)}
      onKeyDown={(e) => {
        if (e.key === "Enter" && title.trim()) {
          onAdd(title.trim());
          setTitle("");
        }
      }}
      placeholder="New task..."
    />
  );
}
 
// Memoized item — only re-renders when its own task changes
const MemoizedTaskItem = memo(function TaskItem({
  task,
  onToggle,
}: {
  task: Task;
  onToggle: (id: string) => void;
}) {
  const start = performance.now();
  while (performance.now() - start < 1) {}
  return (
    <li
      onClick={() => onToggle(task.id)}
      style={{ textDecoration: task.completed ? "line-through" : "none" }}
    >
      {task.title}
    </li>
  );
});

What this demonstrates:

  • State colocation: moving input state to NewTaskInput prevents 500 items from re-rendering on every keystroke
  • React.memo on MemoizedTaskItem skips re-renders when task and onToggle props are unchanged
  • useCallback on toggleTask stabilizes the function reference so memo works
  • useMemo on filtered avoids recomputing the filter on unrelated state changes
  • Total render time drops from ~500ms to under 5ms on each keystroke

Deep Dive

How It Works

  • State change triggers re-render — When setState is called, React re-renders that component and all its descendants. This is the most common re-render trigger.
  • Parent re-render cascades — A parent re-rendering causes all children to re-render, even if their props have not changed. This is React's default behavior and the primary source of wasted renders.
  • Context change triggers re-render — Any component that calls useContext(SomeContext) re-renders whenever the context value changes, regardless of whether the specific field it uses changed.
  • Inline functions create new referencesonClick={() => handleClick(id)} creates a new function on every render. If the child is wrapped in memo, this new reference defeats the optimization.
  • Inline objects create new referencesstyle={{ color: "red" }} or data={{ items }} creates a new object reference each render, breaking shallow comparison in memo.
  • React DevTools Profiler highlights — Enable "Highlight updates when components render" in React DevTools settings. Components flash with a colored border on each render. Frequent flashing during idle or typing indicates wasted renders.

Variations

Children pattern to prevent re-renders:

// BEFORE: SlowChild re-renders when count changes
function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      <SlowChild />
    </div>
  );
}
 
// AFTER: SlowChild passed as children, won't re-render
function CounterWrapper({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      {children}
    </div>
  );
}
 
function Parent() {
  return (
    <CounterWrapper>
      <SlowChild /> {/* Created in Parent scope, not CounterWrapper */}
    </CounterWrapper>
  );
}

Splitting components to isolate state:

// BEFORE: Hover state re-renders the entire card
function ProductCard({ product }: { product: Product }) {
  const [isHovered, setIsHovered] = useState(false);
  return (
    <div
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      <ExpensiveChart data={product.data} />
      <HoverOverlay visible={isHovered} />
    </div>
  );
}
 
// AFTER: Hover state isolated in its own component
function ProductCard({ product }: { product: Product }) {
  return (
    <div>
      <ExpensiveChart data={product.data} />
      <HoverArea />
    </div>
  );
}
 
function HoverArea() {
  const [isHovered, setIsHovered] = useState(false);
  return (
    <div
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      <HoverOverlay visible={isHovered} />
    </div>
  );
}

TypeScript Notes

  • React.memo preserves prop types. For generic components, cast after wrapping: memo(MyComponent) as typeof MyComponent.
  • The useCallback dependency array is type-checked by TypeScript and the react-hooks/exhaustive-deps ESLint rule.
  • When using the children pattern, type children as React.ReactNode for maximum flexibility.

Gotchas

  • Adding memo everywhere without profilingmemo has overhead (shallow comparison on every render). For simple components that render quickly, the comparison cost exceeds the render cost. Fix: Profile first. Only wrap components that the Profiler shows are expensive and re-rendering unnecessarily.

  • Unstable keys causing remounts — Using array index as key or generating keys with Math.random() forces React to unmount and remount components, which is more expensive than a re-render. Fix: Use stable, unique IDs from your data.

  • Forgetting useCallback when passing handlers to memo childrenmemo on the child is useless if the parent passes a new function reference on every render. Fix: Wrap handler functions in useCallback or restructure so the child owns the handler.

  • Context causing global re-renders — A single context with both state and dispatch causes all consumers to re-render on any state change. Fix: Split into separate StateContext and DispatchContext, or use Zustand with selectors.

  • Spreading props defeating memo<Child {...props} /> where props is rebuilt each render creates new object references. Fix: Pass individual props or memoize the props object.

Alternatives

ApproachTrade-off
State colocationSimplest fix; may require restructuring component tree
React.memo + useCallbackPrecise control; adds boilerplate
Children patternZero overhead; only works when state-owning component wraps content
React CompilerAutomatic; experimental, not yet stable
Zustand selectorsReplaces context; adds dependency
VirtualizationRenders only visible items; changes scrolling behavior
useDeferredValueKeeps input responsive; shows stale content briefly

FAQs

What is the most common cause of unnecessary re-renders in React?

A parent component re-rendering causes all its children to re-render, even if their props have not changed. This is React's default behavior and the primary source of wasted renders.

How does state colocation prevent re-renders?

By moving state into the component that actually uses it, parent components no longer re-render when that state changes. For example, moving search input state into a SearchBar component prevents the sibling ProductList from re-rendering on every keystroke.

Why does wrapping a child in React.memo not help if the parent passes an inline function?

Inline functions like onClick={() => handleClick(id)} create a new function reference on every render. memo uses shallow comparison, so it sees a new prop and re-renders the child anyway.

Fix: Wrap the handler in useCallback or restructure so the child owns the handler.

What is the children pattern and when should you use it?
function CounterWrapper({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      {children}
    </div>
  );
}

Children are created in the parent scope, not inside CounterWrapper, so they do not re-render when count changes. Use it when a state-owning component wraps content it does not need to control.

How do you identify unnecessary re-renders using React DevTools?
  • Enable "Highlight updates when components render" in React DevTools settings
  • Components flash with a colored border on each render
  • Frequent flashing during idle or typing indicates wasted renders
  • Use the Profiler tab to record interactions and inspect render durations
Gotcha: Can using array index as a key cause performance problems?

Yes. Using array index as key or generating keys with Math.random() forces React to unmount and remount components instead of updating them. Remounting is more expensive than a re-render. Always use stable, unique IDs from your data.

Why does a single context with both state and dispatch cause global re-renders?

Any component that calls useContext(SomeContext) re-renders whenever the context value changes, regardless of which field changed. If state and dispatch share one context, every state change re-renders all consumers, including those that only dispatch.

Fix: Split into StateContext and DispatchContext, or use Zustand with selectors.

Does spreading props defeat React.memo?

Yes. <Child {...props} /> where props is rebuilt each render creates new object references every time, causing memo to always see "changed" props. Pass individual props or memoize the props object instead.

How do you type the children prop in the children pattern in TypeScript?
function Wrapper({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>;
}

Use React.ReactNode for maximum flexibility. It accepts elements, strings, numbers, fragments, portals, and null.

How do you type a generic component wrapped in React.memo in TypeScript?

React.memo preserves prop types, but for generic components, you need a type assertion after wrapping:

const MemoizedList = memo(MyList) as typeof MyList;

Without the assertion, the generic type parameter is lost.

When should you NOT use React.memo?
  • When the component renders quickly (under 1ms) and the shallow comparison cost exceeds the render cost
  • When props change on almost every render anyway
  • When using the React Compiler, which handles memoization automatically

Always profile first to confirm the component is both expensive and re-rendering unnecessarily.

What is the difference between useDeferredValue and state colocation for handling slow lists?
  • State colocation prevents the slow component from re-rendering at all
  • useDeferredValue still re-renders the slow component but at a lower priority, keeping the input responsive while showing stale content briefly

State colocation is simpler when possible; useDeferredValue is useful when the slow component genuinely depends on the changing state.