Skip to main content

Chapter 35: React Performance Optimization

React is fast by default. When it is not, the fix is usually surgical — a targeted optimization at a specific bottleneck, not a global rewrite. This chapter covers the tools and techniques for identifying and fixing performance issues.

Understanding Re-renders

A React component re-renders when:

  1. Its state changes (useState, useReducer)
  2. Its parent re-renders (unless the child is memoized)
  3. A context it consumes changes
  4. A hook it uses triggers a re-render

Not all re-renders are bad. React's reconciliation is fast. Only optimize when you can measure a problem.

Profiling Before Optimizing

Rule: Never optimize without measuring first. Premature optimization adds complexity without measurable benefit.

React DevTools Profiler

  1. Install React DevTools browser extension
  2. Open DevTools → Profiler tab
  3. Click "Record", interact with your app, click "Stop"
  4. Analyze the flame chart: which components took longest? Which re-rendered unnecessarily?

Web Vitals

// shared/lib/web-vitals.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";

export function reportWebVitals() {
onCLS(console.log); // Cumulative Layout Shift
onINP(console.log); // Interaction to Next Paint (replaced FID)
onLCP(console.log); // Largest Contentful Paint
onFCP(console.log); // First Contentful Paint
onTTFB(console.log); // Time to First Byte
}

React.memo: Prevent Unnecessary Re-renders

// ✅ Memo a component that receives the same props often
const ProjectCard = React.memo(function ProjectCard({ project }: { project: Project }) {
return (
<Card>
<CardHeader>{project.name}</CardHeader>
<CardContent>{project.description}</CardContent>
</Card>
);
});

// The component will only re-render if `project` reference changes
// Shallow comparison by default

When to use React.memo:

  • Component renders frequently with the same props
  • Component is expensive to render (large DOM tree, complex calculations)
  • Component is in a list that re-renders when any item changes

When NOT to use React.memo:

  • Component always receives different props (memo overhead with no benefit)
  • Component is cheap to render (overhead of comparison exceeds render cost)
  • You are guessing rather than measuring

useMemo and useCallback

// useMemo: cache expensive computations
function ProjectAnalytics({ tasks }: { tasks: Task[] }) {
const stats = useMemo(() => ({
total: tasks.length,
completed: tasks.filter((t) => t.status === "DONE").length,
overdue: tasks.filter((t) => t.dueDate && t.dueDate < new Date() && t.status !== "DONE").length,
byPriority: Object.groupBy(tasks, (t) => t.priority),
}), [tasks]); // Only recompute when tasks array changes

return <StatsGrid stats={stats} />;
}

// useCallback: stabilize function references for memoized children
function ProjectList({ projects }: { projects: Project[] }) {
const handleSelect = useCallback((projectId: string) => {
navigate({ to: "/projects/$projectId", params: { projectId } });
}, [navigate]);

return (
<div>
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
onSelect={handleSelect} // Stable reference — memo'd ProjectCard won't re-render
/>
))}
</div>
);
}

Concurrent Features

useTransition: Non-Urgent Updates

function ProjectSearch() {
const [search, setSearch] = useState("");
const [isPending, startTransition] = useTransition();

const handleChange = (value: string) => {
// Urgent: update the input immediately
setSearch(value);

// Non-urgent: update the results list (can be interrupted)
startTransition(() => {
setFilteredProjects(filterProjects(allProjects, value));
});
};

return (
<div>
<Input value={search} onChange={(e) => handleChange(e.target.value)} />
<div className={isPending ? "opacity-60" : ""}>
<ProjectList projects={filteredProjects} />
</div>
</div>
);
}

useDeferredValue: Defer Expensive Renders

function SearchResults({ query }: { query: string }) {
// Defer the query value — React keeps showing old results while computing new ones
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;

const results = useMemo(
() => expensiveSearch(deferredQuery),
[deferredQuery]
);

return (
<div className={isStale ? "opacity-50 transition-opacity" : ""}>
{results.map((result) => (
<ResultItem key={result.id} result={result} />
))}
</div>
);
}

Context Optimization

React Context re-renders ALL consumers when the value changes. Optimize by splitting contexts:

// ❌ One context for everything — every consumer re-renders on any change
const AppContext = createContext({
user: null,
theme: "light",
notifications: [],
sidebarOpen: true,
});

// ✅ Split by update frequency
const AuthContext = createContext<User | null>(null); // Rarely changes
const ThemeContext = createContext<"light" | "dark">("light"); // Rarely changes
const NotificationContext = createContext<Notification[]>([]); // Changes often
// Use Zustand for sidebarOpen — it supports selectors

Virtualization for Long Lists

When rendering hundreds or thousands of items, virtualize:

import { useVirtualizer } from "@tanstack/react-virtual";

function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);

const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
overscan: 5,
});

return (
<div ref={parentRef} className="h-[400px] overflow-auto">
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ListItem item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}

Summary

  • Profile before optimizing — use React DevTools and Web Vitals
  • React.memo for components with stable props that render frequently
  • useMemo for expensive computations; useCallback for stable function references
  • useTransition and useDeferredValue for responsive UIs during heavy updates
  • Split contexts by update frequency to prevent unnecessary re-renders
  • Virtualization for rendering large lists efficiently