Make your app feel snappy by avoiding unnecessary work. This part is practical, beginner‑friendly, and packed with commented examples you can paste in.
🎛️ The Big Ideas
- Render less: memoize components and values so React doesn’t re-render needlessly.
- Do less: avoid heavy computations during render; cache them with
useMemo. - Work smarter: virtualize long lists so the DOM only contains visible rows.
- Measure first: use the React Profiler (and browser Performance tab) to verify improvements.
Golden rule: Measure → Change → Measure again.
1) React.memo — skip re-renders when props didn’t change
// src/components/perf/ExpensiveRow.tsx
import React from 'react';
type RowProps = {
id: string;
title: string;
done: boolean;
onToggle: (id: string) => void;
};
function RowBase({ id, title, done, onToggle }: RowProps) {
// pretend this is heavy (expensive formatting, etc.)
for (let i = 0; i < 200_000; i++); // demo CPU work
return (
<div className="flex items-center gap-2 py-1">
<input type="checkbox" checked={done} onChange={() => onToggle(id)} />
<span className={done ? 'line-through text-gray-500' : ''}>{title}</span>
</div>
);
}
// Wrap with React.memo so the row only re-renders when props change
export const ExpensiveRow = React.memo(RowBase);
Why it helps: If the parent re-renders but a row’s props are identical (by shallow compare), React skips that row.
2) useCallback — keep handler references stable
Without stable callbacks, memoized children still re-render because prop references change.
// src/components/perf/TaskListMemo.tsx
import { useCallback, useState } from 'react';
import { ExpensiveRow } from './ExpensiveRow';
type Task = { id: string; title: string; done: boolean };
export default function TaskListMemo() {
const [tasks, setTasks] = useState<Task[]>([
{ id: '1', title: 'Write docs', done: false },
{ id: '2', title: 'Polish UI', done: true },
]);
// Stable reference: only changes if setTasks changes (it won’t)
const onToggle = useCallback((id: string) => {
setTasks((prev) => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
}, []);
return (
<div>
{tasks.map(t => (
<ExpensiveRow key={t.id} id={t.id} title={t.title} done={t.done} onToggle={onToggle} />
))}
</div>
);
}
Tip: Only add dependencies to useCallback that can change; empty [] is fine for stable setters like setState.
3) useMemo — cache heavy computed values
// src/components/perf/HeavyStats.tsx
import { useMemo } from 'react';
type Props = { items: number[] };
export function HeavyStats({ items }: Props) {
// Expensive calc (e.g., large aggregation)
const { sum, avg } = useMemo(() => {
let s = 0;
for (let i = 0; i < items.length; i++) s += items[i];
const avg = items.length ? s / items.length : 0;
return { sum: s, avg };
}, [items]);
return (
<div className="text-sm">
<div>Sum: {sum}</div>
<div>Avg: {avg.toFixed(2)}</div>
</div>
);
}
Guideline: Memoize only if the computation is non‑trivial or the object/array identity churn causes re-renders downstream.
4) Derived state vs real state
Avoid storing derived values (e.g., filtered = items.filter(...)) in state. Compute them on render or with useMemo.
// ❌ Avoid this
// const [filtered, setFiltered] = useState<Task[]>([]);
// setFiltered(items.filter(...))
// ✅ Prefer derived
// const filtered = useMemo(() => items.filter(...), [items]);
5) Keying lists correctly
- Use stable IDs for
key, not indexes (index keys break when you reorder/insert). - Keys help React reconcile efficiently → fewer DOM ops.
{tasks.map((t) => (
<ExpensiveRow key={t.id} id={t.id} title={t.title} done={t.done} onToggle={onToggle} />
))}
6) Virtualize long lists (render only visible rows)
For thousands of items, even memoization isn’t enough. Virtualization only mounts what’s on screen.
pnpm add react-virtuoso
// src/components/perf/TaskListVirtual.tsx
import { Virtuoso } from 'react-virtuoso';
type Task = { id: string; title: string; done: boolean };
type Props = { items: Task[]; onToggle: (id: string) => void };
export function TaskListVirtual({ items, onToggle }: Props) {
return (
<Virtuoso
totalCount={items.length}
itemContent={(index) => {
const t = items[index];
return (
<div className="px-3 py-2 border-b">
<input type="checkbox" checked={t.done} onChange={() => onToggle(t.id)} />
<span className="ml-2">{t.title}</span>
</div>
);
}}
style={{ height: 400 }}
/>
);
}
Notes: Virtualization libraries (react‑virtuoso, react‑window, react‑virtualized) handle item measurement, windowing, and smooth scrolling.
7) Memoized selectors (when using Redux)
If you compute derived data from the store, use createSelector to avoid recalculation unless inputs change.
// src/store/selectors.ts
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '@/store';
export const selectTasks = (s: RootState) => (s as any).tasks?.items ?? [];
export const selectSearch = (s: RootState) => s.ui.filter.search;
export const selectVisibleTasks = createSelector(
[selectTasks, selectSearch],
(tasks, search) => tasks.filter((t: any) => t.title.toLowerCase().includes(search.toLowerCase())),
);
8) Server-state tuning (when using TanStack Query)
- Increase
staleTimefor data that rarely changes to reduce refetches. - Use
selectin queries to compute once and cache the projection.
useQuery({
queryKey: ['tasks', page],
queryFn: fetchTasks,
staleTime: 60_000, // 1 minute fresh
select: (data) => data.items, // cache just what you need
});
9) Code splitting & Suspense (defer work)
Load heavy pages/components lazily so initial render is fast.
import { lazy, Suspense } from 'react';
const Settings = lazy(() => import('@/pages/settings/Settings'));
export function Routes() {
return (
<Suspense fallback={<p className="p-4">Loading…</p>}>
<Settings />
</Suspense>
);
}
10) Measure: React Profiler (DevTools)
- Open React DevTools → Profiler tab.
- Click Start profiling → interact → Stop.
- Look for components with big render times or many commits.
- Add
memo,useCallback,useMemo, or virtualization where it matters.
You can also wrap code with the <Profiler> API to log:
import { Profiler } from 'react';
function onRender(id: string, phase: 'mount' | 'update', actualDuration: number) {
// send to analytics or console
console.log(`[${id}] ${phase} in ${actualDuration.toFixed(2)}ms`);
}
export function ProfiledList() {
return (
<Profiler id="TaskList" onRender={onRender}>
{/* children */}
</Profiler>
);
}
11) Common pitfalls (and quick fixes)
- Unstable object/array props → move them out or memoize:
// ❌ inline each render <Child options={{ dense: true }} /> // ✅ stable const options = useMemo(() => ({ dense: true }), []); <Child options={options} /> - Large lists without virtualization → adopt
react-virtuosoorreact-window. - Expensive work in render → push into
useMemo/useEffector the server. - Over‑memoization → memo adds overhead; use where it actually helps (after profiling).
✅ Wrap‑Up
You learned how to skip unnecessary renders (memo, useCallback), cache heavy work (useMemo), virtualize lists, and measure with the Profiler. Apply these where profiling shows hotspots, and your UI will feel instantly more responsive.
Part 14 is now on your canvas only. It’s a practical, beginner-friendly performance guide with:
React.memo,useCallback,useMemo- List keying best practices
- Virtualization via
react-virtuoso - Memoized Redux selectors and React Query tuning
- Code-splitting with
lazy/Suspense - Profiler usage and common pitfalls
Comments
Post a Comment