| name | react-performance-patterns |
| description | Battle-tested patterns for optimizing React applications, from component design to bundle optimization |
React Performance Patterns
You are an expert in React performance optimization. You help developers identify performance bottlenecks, implement efficient rendering patterns, and build fast, responsive React applications. Your guidance is based on real-world production experience and current best practices.
Core Performance Principles
1. Measure Before Optimizing
Never optimize blindly. Always profile first:
import { Profiler } from 'react';
function onRenderCallback(
id: string,
phase: 'mount' | 'update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) {
console.log({ id, phase, actualDuration });
}
<Profiler id="ExpensiveComponent" onRender={onRenderCallback}>
<ExpensiveComponent />
</Profiler>
Key metrics to track:
- Render count
- Render duration
- Component mount/update time
- Bundle size
- Network waterfall
- Core Web Vitals (LCP, INP, CLS)
2. Avoid Unnecessary Renders
React re-renders when:
- State changes
- Props change
- Parent re-renders
- Context value changes
Your job: minimize wasted renders.
3. Code Split Aggressively
Users shouldn't download code for pages they never visit.
4. Optimize Heavy Operations
Move expensive calculations off the main thread or cache results.
Pattern 1: Memoization
React.memo() - Prevent Component Re-renders
function UserCard({ user }: { user: User }) {
return <div>{user.name}</div>;
}
const UserCard = memo(({ user }: { user: User }) => {
return <div>{user.name}</div>;
});
When to use:
- Pure functional components
- Components that render frequently with same props
- Expensive render operations
- List items
When NOT to use:
- Component always receives new props
- Props contain objects/arrays created inline
- Component is already fast
useMemo() - Memoize Expensive Calculations
function DataTable({ data, filters }: Props) {
const filtered = data.filter(item =>
filters.every(f => f.fn(item))
);
const filtered = useMemo(
() => data.filter(item => filters.every(f => f.fn(item))),
[data, filters]
);
return <Table data={filtered} />;
}
When to use:
- Filtering/sorting large arrays
- Complex calculations
- Derived data that's expensive to compute
- Creating objects/arrays passed as props
Cost/benefit check:
const doubled = useMemo(() => count * 2, [count]);
const sorted = useMemo(
() => items.sort((a, b) => expensiveCompare(a, b)),
[items]
);
useCallback() - Memoize Functions
function Parent() {
const handleClick = () => {
doSomething();
};
const handleClick = useCallback(() => {
doSomething();
}, []);
return <MemoizedChild onClick={handleClick} />;
}
When to use:
- Passing callbacks to memoized children
- Dependencies in useEffect/useMemo
- Creating stable event handlers
- Working with debounce/throttle
Common mistake:
const handleClick = useCallback(() => {
doSomething(data);
}, [data]);
const { id, name } = data;
const handleClick = useCallback(() => {
doSomething({ id, name });
}, [id, name]);
Pattern 2: Code Splitting & Lazy Loading
Component-Level Code Splitting
import { lazy, Suspense } from 'react';
import HeavyChart from './HeavyChart';
import AdminPanel from './AdminPanel';
const HeavyChart = lazy(() => import('./HeavyChart'));
const AdminPanel = lazy(() => import('./AdminPanel'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>
Show Chart
</button>
{showChart && (
<Suspense fallback={<Skeleton />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
Route-Based Code Splitting
export default function DashboardPage() {
return <Dashboard />;
}
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
function App() {
return (
<Routes>
<Route
path="/dashboard"
element={
<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>
}
/>
<Route
path="/profile"
element={
<Suspense fallback={<Spinner />}>
<Profile />
</Suspense>
}
/>
</Routes>
);
}
Preloading for Better UX
function Navigation() {
const handleMouseEnter = () => {
import('./pages/Dashboard');
};
return (
<Link
to="/dashboard"
onMouseEnter={handleMouseEnter}
>
Dashboard
</Link>
);
}
Pattern 3: Virtualization (Long Lists)
For lists with 100+ items, render only what's visible.
Using react-window
import { FixedSizeList } from 'react-window';
function BadList({ items }: { items: Item[] }) {
return (
<div>
{items.map(item => (
<ItemRow key={item.id} item={item} />
))}
</div>
);
}
function GoodList({ items }: { items: Item[] }) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<ItemRow item={items[index]} />
</div>
)}
</FixedSizeList>
);
}
Performance impact:
- 10,000 items without virtualization: 5-10 second render
- 10,000 items with virtualization: 50-100ms render
Dynamic Size Lists
import { VariableSizeList } from 'react-window';
function DynamicList({ items }: { items: Item[] }) {
const getItemSize = (index: number) => {
return items[index].type === 'large' ? 120 : 60;
};
return (
<VariableSizeList
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<ItemRow item={items[index]} />
</div>
)}
</VariableSizeList>
);
}
Pattern 4: Optimize Context Usage
Problem: Context Causes Unnecessary Re-renders
const AppContext = createContext<AppState>(null);
function AppProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [notifications, setNotifications] = useState<Notification[]>([]);
const value = { user, setUser, theme, setTheme, notifications, setNotifications };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
function UserAvatar() {
const { user } = useContext(AppContext);
return <Avatar user={user} />;
}
Solution 1: Split Contexts
const UserContext = createContext<UserState>(null);
const ThemeContext = createContext<ThemeState>(null);
const NotificationContext = createContext<NotificationState>(null);
function UserAvatar() {
const { user } = useContext(UserContext);
return <Avatar user={user} />;
}
Solution 2: Context Selectors
import create from 'zustand';
const useStore = create<AppState>((set) => ({
user: null,
theme: 'light',
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
}));
function UserAvatar() {
const user = useStore((state) => state.user);
return <Avatar user={user} />;
}
function ThemeSwitcher() {
const theme = useStore((state) => state.theme);
return <ThemeToggle theme={theme} />;
}
Solution 3: Memoize Context Value
function AppProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const value = useMemo(
() => ({ user, setUser }),
[user]
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
Pattern 5: Debounce & Throttle
Debounce: Wait for User to Stop Typing
import { useDebouncedCallback } from 'use-debounce';
function SearchInput() {
const [query, setQuery] = useState('');
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
searchAPI(value);
};
const debouncedSearch = useDebouncedCallback(
(value: string) => {
searchAPI(value);
},
300
);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
};
return <input value={query} onChange={handleChange} />;
}
Custom debounce hook:
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
function SearchResults() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
searchAPI(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
Throttle: Limit Execution Frequency
import { useThrottledCallback } from 'use-debounce';
function InfiniteScroll() {
const handleScroll = () => {
if (isNearBottom()) {
loadMore();
}
};
const throttledScroll = useThrottledCallback(
() => {
if (isNearBottom()) {
loadMore();
}
},
200
);
return <div onScroll={throttledScroll}>{/* content */}</div>;
}
Pattern 6: Optimize Images
Use Next.js Image Component
import Image from 'next/image';
<img src="/hero.jpg" alt="Hero" />
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={630}
priority // For above-fold images
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
Benefits:
- Automatic WebP/AVIF format
- Responsive image sizes
- Lazy loading by default
- Prevents layout shift (width/height specified)
- Built-in blur placeholder
For Non-Next.js Projects
<img
src="/hero-800.webp"
srcSet="
/hero-400.webp 400w,
/hero-800.webp 800w,
/hero-1200.webp 1200w
"
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
alt="Hero image"
width="1200"
height="630"
loading="lazy"
/>
Pattern 7: Optimize Heavy Computations
Web Workers for CPU-Intensive Tasks
self.onmessage = (e: MessageEvent) => {
const { data } = e;
const result = data.map((item) => {
return complexCalculation(item);
});
self.postMessage(result);
};
function DataProcessor({ data }: { data: number[] }) {
const [result, setResult] = useState<number[]>([]);
useEffect(() => {
const worker = new Worker(new URL('./worker.ts', import.meta.url));
worker.postMessage(data);
worker.onmessage = (e: MessageEvent) => {
setResult(e.data);
};
return () => worker.terminate();
}, [data]);
return <Chart data={result} />;
}
Incremental Rendering (Time Slicing)
import { useTransition } from 'react';
function SearchResults() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
startTransition(() => {
setSearchResults(search(value));
});
};
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<Results data={searchResults} />
</>
);
}
Pattern 8: Bundle Optimization
Analyze Your Bundle
ANALYZE=true npm run build
npm install --save-dev webpack-bundle-analyzer
Tree Shaking: Import Only What You Need
import _ from 'lodash';
const doubled = _.map(arr, n => n * 2);
import map from 'lodash-es/map';
const doubled = map(arr, n => n * 2);
const doubled = arr.map(n => n * 2);
Dynamic Imports for Third-Party Libraries
import { Chart } from 'chart.js';
function ChartComponent({ data }: { data: ChartData }) {
const [Chart, setChart] = useState<any>(null);
useEffect(() => {
import('chart.js').then((module) => {
setChart(() => module.Chart);
});
}, []);
if (!Chart) return <Skeleton />;
return <Chart data={data} />;
}
Remove Unused Dependencies
npx depcheck
npm uninstall unused-package
Pattern 9: Avoid Inline Object/Array Creation
The Problem
function UserList() {
return (
<MemoizedComponent
style={{ color: 'red' }} // New object
options={['a', 'b', 'c']} // New array
/>
);
}
Even though MemoizedComponent is memoized, it re-renders because props are new objects.
The Fix
const STYLE = { color: 'red' };
const OPTIONS = ['a', 'b', 'c'];
function UserList() {
return (
<MemoizedComponent
style={STYLE}
options={OPTIONS}
/>
);
}
function UserList({ color }: { color: string }) {
const style = useMemo(() => ({ color }), [color]);
return <MemoizedComponent style={style} />;
}
Pattern 10: Optimize Forms
Controlled vs Uncontrolled Inputs
function Form() {
const [formData, setFormData] = useState({ name: '', email: '' });
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
return (
<form>
<input name="name" value={formData.name} onChange={handleChange} />
<input name="email" value={formData.email} onChange={handleChange} />
<ExpensiveComponent /> {/* Re-renders on every keystroke! */}
</form>
);
}
import { useForm } from 'react-hook-form';
function Form() {
const { register, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
<input {...register('email')} />
<ExpensiveComponent /> {/* No unnecessary re-renders */}
</form>
);
}
Profiling & Debugging
React DevTools Profiler
- Open React DevTools
- Go to Profiler tab
- Click Record
- Interact with your app
- Stop recording
- Analyze:
- Which components rendered?
- How long did they take?
- Why did they render?
Chrome Performance Tab
- Open Chrome DevTools > Performance
- Click Record
- Interact with your app
- Stop recording
- Analyze:
- Long tasks (> 50ms)
- Layout thrashing
- JavaScript execution time
Lighthouse
npm install -g lighthouse
lighthouse https://yoursite.com --view
Checks:
- Performance score
- First Contentful Paint
- Largest Contentful Paint
- Time to Interactive
- Total Blocking Time
- Cumulative Layout Shift
Performance Budget
Set and enforce budgets:
{
"budgets": [
{
"resourceSizes": [
{ "resourceType": "script", "budget": 300 },
{ "resourceType": "image", "budget": 500 },
{ "resourceType": "stylesheet", "budget": 50 }
],
"resourceCounts": [
{ "resourceType": "third-party", "budget": 10 }
]
}
]
}
Quick Wins Checklist
Resources
When optimizing React apps, focus on the biggest bottlenecks first. Use profiling tools to identify issues, then apply these patterns systematically. Remember: premature optimization is the root of all evil, but measured, targeted optimization is the path to performant apps.