Performance and Best Practices
Introduction
React 18 is fast by default, but large lists, unnecessary re-renders, and bundle bloat still slow real apps. This chapter covers practical habits—lazy routes, memo, useMemo, project structure, and accessibility—without premature optimization.
Prerequisites
Measure Before Optimizing
- React DevTools — Profiler tab, highlight updates
- Browser Performance — long tasks, layout thrashing
- Network — bundle size, API waterfall
- Lighthouse — LCP, INP, CLS
Fix the biggest bottleneck first—often network or one heavy component.
Compare Vue performance chapter.
Route and Component Lazy Loading
const ReportsPage = lazy(() => import("./pages/ReportsPage"));
<Suspense fallback={<p>Loading…</p>}>
<ReportsPage />
</Suspense>Manual vendor chunks in vite.config.ts:
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ["react", "react-dom", "react-router-dom"],
},
},
},
},React.memo
Skip re-render when props are unchanged:
import { memo } from "react";
type RowProps = { id: string; label: string };
export const HeavyRow = memo(function HeavyRow({ id, label }: RowProps) {
return <tr><td>{id}</td><td>{label}</td></tr>;
});Use when Profiler shows expensive child re-rendering with same props—not everywhere by default.
useMemo and useCallback
const filtered = useMemo(
() => todos.filter((t) => !t.done),
[todos]
);
const handleSave = useCallback(() => {
saveTodo(draft);
}, [draft]);| Hook | When it helps |
|---|---|
useMemo | Expensive filter/sort; stable object for effect deps |
useCallback | Pass stable fn to memo child |
Avoid wrapping every function—measure first.
List Performance
| Problem | Direction |
|---|---|
| 10,000 DOM rows | Virtual scroll (@tanstack/react-virtual) |
Unstable key | Stable ids, not index |
| Filter in render | useMemo filtered list |
Do not render 10k <tr> elements—virtualize.
Zustand Selectors
// Re-renders on any store change
const store = useTodoStore();
// Better — subscribe to slice
const items = useTodoStore((s) => s.items);Image and Asset Loading
<img src="/hero.webp" alt="Hero" loading="lazy" width={800} height={400} />- Modern formats (WebP, AVIF)
- Explicit dimensions reduce CLS
loading="lazy"below the fold
Keep Components Focused
| Smell | Fix |
|---|---|
| 500-line component | Split UI + custom hooks |
| God component | Feature folders + Zustand |
| Duplicate fetch | usePosts() hook |
src/features/posts/
├── components/
├── hooks/
├── stores/
├── pages/
└── types.tsAccessibility Basics
- Semantic HTML:
<button>not<div onClick> <label htmlFor="email">linked to inputs- Visible focus styles
aria-livefor dynamic errors
Accessible CSS, HTML accessible forms.
Security Habits
- No
dangerouslySetInnerHTMLon user content - CSP headers in production — Nginx
Anti-Patterns
| Anti-pattern | Why |
|---|---|
useEffect for everything | Derive in render or event handlers |
| Prop drilling 10 levels | Context or Zustand |
| Mutate state in place | Breaks React updates |
memo every component | Wasted comparison cost |
| Giant bundle import | Lazy routes + tree-shake icons |
FAQ
React slower than Vue?
Both are fast; architecture and bundle size matter more.
Context performance?
Split contexts; prefer Zustand for frequent updates.
Devtools "rendered because parent rendered"?
Lift memo, stabilize callbacks, or split state.
SSR for performance?
SSR helps first paint—not always faster TTI; measure.
Import entire icon library?
Import individual icons—full library bloats bundle.
Profile hook cost?
Hooks are cheap—optimize render work and child trees.