React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

performancememouseMemouseCallbackprofilingre-rendersreact-patterns

React Performance Optimization — Profile, measure, and prevent unnecessary re-renders

Recipe

import { memo, useMemo, useCallback, useTransition } from "react";
 
// Memoize expensive component
const ExpensiveList = memo(function ExpensiveList({
  items,
  onSelect,
}: {
  items: Item[];
  onSelect: (id: string) => void;
}) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});
 
// Parent: stabilize props passed to memoized children
function Parent() {
  const [items] = useState<Item[]>(initialItems);
  const [selected, setSelected] = useState<string | null>(null);
 
  // Stable callback reference
  const handleSelect = useCallback((id: string) => {
    setSelected(id);
  }, []);
 
  return <ExpensiveList items={items} onSelect={handleSelect} />;
}

When to reach for this: Only after profiling reveals a performance problem. Premature optimization adds complexity for no gain. Use React DevTools Profiler to measure first, then apply memo, useMemo, or useCallback where they actually help.

Working Example

import {
  memo,
  useMemo,
  useCallback,
  useState,
  useTransition,
  useDeferredValue,
} from "react";
 
// --- Virtualized list with memoized rows ---
 
interface Contact {
  id: string;
  name: string;
  email: string;
  avatar: string;
}
 
const ContactRow = memo(function ContactRow({
  contact,
  isSelected,
  onSelect,
}: {
  contact: Contact;
  isSelected: boolean;
  onSelect: (id: string) => void;
}) {
  return (
    <div
      onClick={() => onSelect(contact.id)}
      className={`flex items-center gap-3 p-3 cursor-pointer ${
        isSelected ? "bg-blue-50 border-l-4 border-blue-500" : "hover:bg-gray-50"
      }`}
    >
      <img
        src={contact.avatar}
        alt=""
        className="w-10 h-10 rounded-full"
        loading="lazy"
      />
      <div>
        <p className="font-medium">{contact.name}</p>
        <p className="text-sm text-gray-500">{contact.email}</p>
      </div>
    </div>
  );
});
 
// --- Search with deferred value ---
 
function ContactList({ contacts }: { contacts: Contact[] }) {
  const [query, setQuery] = useState("");
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
 
  // Expensive filter only re-runs when deferredQuery or contacts change
  const filtered = useMemo(
    () =>
      contacts.filter(
        (c) =>
          c.name.toLowerCase().includes(deferredQuery.toLowerCase()) ||
          c.email.toLowerCase().includes(deferredQuery.toLowerCase())
      ),
    [contacts, deferredQuery]
  );
 
  const handleSelect = useCallback((id: string) => {
    setSelectedId(id);
  }, []);
 
  return (
    <div className="max-w-md border rounded-lg overflow-hidden">
      <div className="p-3 border-b">
        <input
          type="search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search contacts..."
          className="w-full px-3 py-2 border rounded"
        />
      </div>
      <div
        className={`max-h-96 overflow-y-auto ${isStale ? "opacity-60" : ""}`}
      >
        {filtered.length === 0 ? (
          <p className="p-4 text-gray-500 text-center">No contacts found</p>
        ) : (
          filtered.map((contact) => (
            <ContactRow
              key={contact.id}
              contact={contact}
              isSelected={contact.id === selectedId}
              onSelect={handleSelect}
            />
          ))
        )}
      </div>
      <div className="p-2 border-t text-sm text-gray-400 text-center">
        {filtered.length} of {contacts.length} contacts
      </div>
    </div>
  );
}
 
// --- Transition for tab switching ---
 
function TabPanel({ tabs }: { tabs: { id: string; label: string; content: () => JSX.Element }[] }) {
  const [activeTab, setActiveTab] = useState(tabs[0].id);
  const [isPending, startTransition] = useTransition();
 
  const handleTabChange = (tabId: string) => {
    startTransition(() => {
      setActiveTab(tabId);
    });
  };
 
  const ActiveContent = tabs.find((t) => t.id === activeTab)?.content;
 
  return (
    <div>
      <div className="flex border-b">
        {tabs.map((tab) => (
          <button
            key={tab.id}
            onClick={() => handleTabChange(tab.id)}
            className={`px-4 py-2 -mb-px ${
              activeTab === tab.id
                ? "border-b-2 border-blue-500 font-medium"
                : "text-gray-500"
            }`}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div className={`p-4 ${isPending ? "opacity-50 transition-opacity" : ""}`}>
        {ActiveContent && <ActiveContent />}
      </div>
    </div>
  );
}

What this demonstrates:

  • memo on ContactRow skips re-renders when props haven't changed
  • useCallback stabilizes handleSelect so it doesn't break memo
  • useDeferredValue keeps the search input responsive while filtering is deferred
  • useMemo caches the filtered list to avoid re-computing on every keystroke
  • useTransition marks tab switching as non-urgent, keeping interactions smooth

Deep Dive

How It Works

  • React re-renders a component when its state changes, its parent re-renders, or a context it consumes changes.
  • React.memo(Component) wraps a component so it only re-renders when its props change (shallow comparison by default).
  • useMemo(fn, deps) caches a computed value and only recomputes when dependencies change.
  • useCallback(fn, deps) caches a function reference — equivalent to useMemo(() => fn, deps).
  • useDeferredValue(value) returns a deferred copy that lags behind during urgent updates, allowing the input to stay responsive.
  • useTransition wraps state updates as non-urgent, allowing React to interrupt the render if a more urgent update arrives.
  • The React compiler (experimental) can auto-memoize components and hooks, potentially making manual memo/useMemo/useCallback unnecessary.

Parameters & Return Values

APIInputOutputRe-render Behavior
memo(Component, areEqual?)Component, optional comparatorMemoized componentSkips render if props are shallowly equal
useMemo(fn, deps)Factory function, dependency arrayCached value of type TRecomputes only when deps change
useCallback(fn, deps)Function, dependency arrayStable function referenceNew reference only when deps change
useDeferredValue(value)Any valueDeferred copy of valueUpdates with lower priority
useTransition()None[isPending, startTransition]Marks updates as interruptible

Variations

Custom comparison for memo:

const Chart = memo(
  function Chart({ data, config }: ChartProps) {
    // Expensive render
    return <canvas />;
  },
  (prev, next) => {
    // Only re-render if data length or config changes
    return (
      prev.data.length === next.data.length &&
      prev.config.type === next.config.type
    );
  }
);

Profiling with React DevTools:

import { Profiler, type ProfilerOnRenderCallback } from "react";
 
const onRender: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) => {
  console.table({ id, phase, actualDuration, baseDuration });
};
 
function App() {
  return (
    <Profiler id="ContactList" onRender={onRender}>
      <ContactList contacts={contacts} />
    </Profiler>
  );
}

Avoiding re-renders with children pattern:

// This causes SearchResults to re-render when count changes:
function BadParent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <SearchResults /> {/* Re-renders unnecessarily */}
    </div>
  );
}
 
// Fix: lift state into a smaller component
function GoodParent() {
  return (
    <div>
      <Counter /> {/* count state scoped here */}
      <SearchResults /> {/* No longer re-renders */}
    </div>
  );
}
 
function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

TypeScript Notes

  • memo preserves the component's prop types. Generic components require a wrapper: memo(Component) as typeof Component.
  • useMemo<T> and useCallback infer types from the factory function. Explicit type annotations are rarely needed.
  • The areEqual function in memo receives (prevProps, nextProps) and should return true to skip re-render.

Gotchas

  • Premature optimization — Adding memo, useMemo, and useCallback everywhere adds complexity and can actually slow down fast components due to comparison overhead. Fix: Profile first with React DevTools. Only optimize components that show measurable re-render cost.

  • Unstable prop references breaking memo — Passing onClick={() => {}} or style={{ color: "red" }} inline creates new references each render, defeating memo. Fix: Use useCallback for functions and useMemo for objects/arrays passed to memoized children.

  • Missing dependency in useMemo/useCallback — Omitting a dependency leads to stale closures. Fix: Include all referenced values in the dependency array. Use the react-hooks/exhaustive-deps ESLint rule.

  • Memoizing cheap computationsuseMemo for a simple string concatenation costs more than just recomputing it. Fix: Only memoize computations that are actually expensive (filtering large arrays, complex calculations, creating objects for context).

  • Key prop resetting memo — Changing a component's key causes React to unmount and remount it, regardless of memo. Fix: Use stable keys (IDs, not array indices) unless you intentionally want a reset.

  • Large component trees without virtualization — Memoizing 10,000 list items is slower than virtualizing to render only visible items. Fix: Use @tanstack/react-virtual, react-window, or CSS content-visibility: auto for large lists.

Alternatives

ApproachTrade-off
React.memo + useCallbackManual but precise; boilerplate
React Compiler (experimental)Automatic memoization; experimental, not production-ready
VirtualizationRenders only visible items; more complex scrolling behavior
State colocationMove state closer to where it's used; simplest fix
useDeferredValueKeeps UI responsive; shows stale data briefly
CSS content-visibilityBrowser-native lazy rendering; no React API needed
Web WorkersOffload computation; complex data transfer

FAQs

When should you add React.memo, useMemo, or useCallback to a component?
  • Only after profiling with React DevTools Profiler reveals a measurable re-render cost.
  • Premature optimization adds complexity and comparison overhead that can slow down fast components.
  • Focus on components that render large lists, expensive computations, or deep trees.
What triggers a React component to re-render?
  • Its own state changes via useState or useReducer.
  • Its parent re-renders (even if no props changed, unless wrapped in memo).
  • A context it consumes changes.
How does useDeferredValue keep the UI responsive during expensive filtering?
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
 
const filtered = useMemo(
  () => contacts.filter((c) => c.name.includes(deferredQuery)),
  [contacts, deferredQuery]
);
  • The input updates immediately with query, but deferredQuery lags behind.
  • The expensive filter runs on the deferred value, so typing stays responsive.
  • isStale can be used to show a visual indicator (e.g., reduced opacity) while filtering catches up.
What is the difference between useTransition and useDeferredValue?
  • useTransition wraps a state update to mark it as non-urgent, keeping the current UI visible.
  • useDeferredValue creates a deferred copy of a value that updates with lower priority.
  • Use useTransition when you control the state update. Use useDeferredValue when you receive a value from props.
Gotcha: Why does passing inline objects or functions as props break React.memo?
  • Inline onClick={() => {}} or style={{ color: "red" }} creates a new reference every render.
  • React.memo compares props by reference, so it sees a "change" and re-renders.
  • Fix: use useCallback for functions and useMemo for objects/arrays passed to memoized children.
Gotcha: Why is memoizing cheap computations actually slower?
  • useMemo has overhead: it stores the previous value, compares dependencies, and manages memory.
  • For a simple string concatenation or basic arithmetic, recomputing is faster than the memoization overhead.
  • Only memoize computations that are actually expensive (filtering large arrays, complex calculations).
How does the children-as-props pattern avoid unnecessary parent re-renders?
// Bad: SearchResults re-renders when count changes
function BadParent() {
  const [count, setCount] = useState(0);
  return <div><button onClick={() => setCount(c => c + 1)}>{count}</button><SearchResults /></div>;
}
 
// Good: move state into a smaller component
function GoodParent() {
  return <div><Counter /><SearchResults /></div>;
}
  • Moving state into a smaller component scopes re-renders to only that component.
  • SearchResults no longer re-renders because its parent (GoodParent) does not own the changing state.
How does memo interact with generic components in TypeScript?
  • memo(Component) can lose generic type parameters.
  • Fix: cast the result back: memo(Component) as typeof Component.
  • This preserves the generic signature for consumers.
What does the areEqual function in React.memo do in TypeScript?
const Chart = memo(
  function Chart({ data, config }: ChartProps) { return <canvas />; },
  (prev, next) => prev.data.length === next.data.length && prev.config.type === next.config.type
);
  • It receives (prevProps, nextProps) and returns true to skip re-render, false to re-render.
  • Use it for custom comparison logic when shallow equality is not enough.
When should you use virtualization instead of memoization for large lists?
  • When you have thousands of items, memoizing each one is slower than rendering only the visible items.
  • Libraries like @tanstack/react-virtual and react-window render only items in the viewport.
  • CSS content-visibility: auto provides browser-native lazy rendering without a React library.
What is the React Compiler and how does it relate to manual memoization?
  • The React Compiler (experimental) can auto-memoize components and hooks at build time.
  • It could make manual memo, useMemo, and useCallback unnecessary.
  • It is not yet production-ready, so manual memoization is still the standard approach.
  • Context Patterns — Splitting context to reduce re-renders
  • SuspenseuseTransition and useDeferredValue for async rendering
  • Composition — Children-as-props pattern avoids parent re-renders