React Performance: Optimization Patterns for Enterprise Applications
Practical techniques for optimizing React applications at scale. Virtualization, code splitting, state management patterns, and profiling strategies.
React Performance: Optimization Patterns for Enterprise Applications
Large React applications can become sluggish without intentional performance optimization. After working on enterprise dashboards rendering thousands of data points and complex forms with hundreds of fields, I've developed a systematic approach to React performance that goes beyond the basics.
Understanding React's Rendering Model
Before optimizing, understand why React re-renders:
Re-render triggers:
1. State change in the component
2. Props change (by reference, not value)
3. Parent component re-renders
4. Context value changesThe Re-render Problem
// Parent re-renders → All children re-render
function Dashboard() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
return (
<div>
<Clock time={time} />
<ExpensiveChart /> {/* Re-renders every second! */}
<DataTable /> {/* Re-renders every second! */}
</div>
);
}Rendering Optimization
React.memo for Expensive Components
Wrap components that don't need frequent updates:
// Before: Re-renders on every parent update
function ExpensiveChart({ data }) {
// Complex chart rendering
}
// After: Only re-renders when data changes
const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
// Complex chart rendering
});
// Custom comparison for complex props
const DataTable = React.memo(
function DataTable({ rows, columns }) {
// Table rendering
},
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return (
prevProps.rows.length === nextProps.rows.length &&
prevProps.rows.every((row, i) => row.id === nextProps.rows[i].id)
);
}
);Strategic useMemo and useCallback
Don't memoize everything—it adds overhead. Memoize when:
// GOOD: Expensive computation
const sortedData = useMemo(() => {
return [...data].sort((a, b) => {
// Complex multi-field sorting
return compareMultipleFields(a, b, sortConfig);
});
}, [data, sortConfig]);
// GOOD: Stable reference for child component props
const handleClick = useCallback((id: string) => {
setSelected(id);
}, []);
// BAD: Simple computation (overhead > benefit)
const fullName = useMemo(() => `${first} ${last}`, [first, last]);
// BAD: Function that doesn't need stability
const formatDate = useCallback((date: Date) => {
return date.toLocaleDateString();
}, []); // No dependencies, could be module-level functionAvoid Inline Object/Array Creation
Inline objects create new references every render:
// BAD: New object every render, defeats React.memo
<UserProfile
style={{ marginTop: 20, padding: 10 }}
user={{ name, email }}
/>
// GOOD: Stable references
const profileStyle = useMemo(() => ({
marginTop: 20,
padding: 10
}), []);
const userProp = useMemo(() => ({
name, email
}), [name, email]);
<UserProfile style={profileStyle} user={userProp} />
// BETTER: Define outside component if static
const profileStyle = { marginTop: 20, padding: 10 };
function Parent() {
return <UserProfile style={profileStyle} user={user} />;
}Key Selection for Lists
Keys affect reconciliation performance:
// BAD: Array index as key (causes issues with reordering)
{items.map((item, index) => (
<ListItem key={index} item={item} />
))}
// GOOD: Stable unique ID
{items.map((item) => (
<ListItem key={item.id} item={item} />
))}
// IMPORTANT: Keys should be stable across renders
// If items are reordered, index-based keys cause:
// - Unnecessary DOM updates
// - State bugs in child components
// - Input focus issuesCode Splitting
Route-Based Splitting
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Lazy load route components
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}Component-Level Splitting
Split heavy components that aren't immediately visible:
// Heavy chart library loaded only when modal opens
const ChartModal = lazy(() => import('./ChartModal'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<ChartModal onClose={() => setShowChart(false)} />
</Suspense>
)}
</div>
);
}Named Exports with Lazy Loading
// For components with named exports
const DataGrid = lazy(() =>
import('./DataGrid').then((module) => ({
default: module.DataGrid
}))
);Virtualization
When to Virtualize
| List Size | Recommendation |
|---|---|
| < 100 items | No virtualization needed |
| 100-1000 items | Consider virtualization |
| > 1000 items | Virtualization essential |
react-window Implementation
import { FixedSizeList } from 'react-window';
interface RowData {
id: string;
name: string;
email: string;
}
function VirtualizedList({ items }: { items: RowData[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
const item = items[index];
return (
<div style={style} className="list-row">
<span>{item.name}</span>
<span>{item.email}</span>
</div>
);
};
return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={50}
>
{Row}
</FixedSizeList>
);
}Variable Size Items
import { VariableSizeList } from 'react-window';
function ChatMessages({ messages }) {
const listRef = useRef<VariableSizeList>(null);
// Calculate item height based on content
const getItemSize = (index: number) => {
const message = messages[index];
const lineCount = Math.ceil(message.text.length / 50);
return 40 + lineCount * 20; // Base + content height
};
// Reset size cache when messages change
useEffect(() => {
listRef.current?.resetAfterIndex(0);
}, [messages]);
return (
<VariableSizeList
ref={listRef}
height={400}
itemCount={messages.length}
itemSize={getItemSize}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<MessageBubble message={messages[index]} />
</div>
)}
</VariableSizeList>
);
}State Management Patterns
Colocate State
Move state as close to where it's used as possible:
// BAD: Global state for local concern
function App() {
const [selectedTab, setSelectedTab] = useState('overview');
return <Dashboard selectedTab={selectedTab} onTabChange={setSelectedTab} />;
}
// GOOD: State lives where it's used
function Dashboard() {
const [selectedTab, setSelectedTab] = useState('overview');
return <TabPanel selectedTab={selectedTab} onTabChange={setSelectedTab} />;
}Split Context for Different Update Frequencies
// BAD: One context, everything re-renders
const AppContext = createContext({ user: null, theme: 'light', notifications: [] });
// GOOD: Separate contexts by update frequency
const UserContext = createContext(null); // Rarely changes
const ThemeContext = createContext('light'); // Occasionally changes
const NotificationContext = createContext([]); // Frequently changes
function App() {
return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<NotificationContext.Provider value={notifications}>
<Dashboard />
</NotificationContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}Zustand for Selective Subscriptions
import { create } from 'zustand';
interface Store {
user: User | null;
items: Item[];
filter: string;
setFilter: (filter: string) => void;
addItem: (item: Item) => void;
}
const useStore = create<Store>((set) => ({
user: null,
items: [],
filter: '',
setFilter: (filter) => set({ filter }),
addItem: (item) => set((state) => ({ items: [...state.items, item] }))
}));
// Components subscribe to specific slices
function FilterBar() {
// Only re-renders when filter changes
const filter = useStore((state) => state.filter);
const setFilter = useStore((state) => state.setFilter);
return <input value={filter} onChange={(e) => setFilter(e.target.value)} />;
}
function ItemList() {
// Only re-renders when items or filter change
const items = useStore((state) => state.items);
const filter = useStore((state) => state.filter);
const filtered = useMemo(() =>
items.filter(item => item.name.includes(filter)),
[items, filter]
);
return <VirtualizedList items={filtered} />;
}Profiling and Measurement
React DevTools Profiler
// Enable profiling in production builds
// In your build config:
// {
// "react": "react/profiling",
// "scheduler/tracing": "scheduler/tracing-profiling"
// }
// Programmatic profiling
import { Profiler } from 'react';
function onRenderCallback(
id: string,
phase: 'mount' | 'update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) {
// Log slow renders
if (actualDuration > 16) {
console.warn(`Slow render: ${id} took ${actualDuration}ms`);
}
}
<Profiler id="Dashboard" onRender={onRenderCallback}>
<Dashboard />
</Profiler>Why Did You Render
// Development tool to identify unnecessary re-renders
import whyDidYouRender from '@welldone-software/why-did-you-render';
if (process.env.NODE_ENV === 'development') {
whyDidYouRender(React, {
trackAllPureComponents: true,
});
}
// Mark specific components for tracking
ExpensiveComponent.whyDidYouRender = true;Performance Marks
function DataTable({ data }) {
useEffect(() => {
performance.mark('DataTable-render-start');
return () => {
performance.mark('DataTable-render-end');
performance.measure(
'DataTable-render',
'DataTable-render-start',
'DataTable-render-end'
);
};
});
// ...
}Common Performance Pitfalls
1. Prop Drilling Through Many Levels
Problem: Changes at top level re-render entire tree
Solution: Context, composition, or state management library
2. Uncontrolled Form Re-renders
Problem: Form state in parent causes re-renders of all fields
Solution: react-hook-form or similar libraries
import { useForm } from 'react-hook-form';
function LargeForm() {
const { register, handleSubmit } = useForm();
// Individual fields don't re-render when others change
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('firstName')} />
<input {...register('lastName')} />
{/* ... hundreds more fields */}
</form>
);
}3. Fetching in Components
Problem: Multiple components fetch same data
Solution: Use React Query or SWR for deduplication and caching
import { useQuery } from '@tanstack/react-query';
function useUser(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Multiple components can call useUser(userId)
// Only one network request will be madeKey Takeaways
- Measure first: Profile before optimizing—don't guess at bottlenecks
- React.memo strategically: Not everything needs memoization—it has overhead
- Colocate state: Global state causes global re-renders
- Virtualize long lists: Don't render what users can't see
- Split code by routes: Lazy load pages and heavy components
- Use stable references: Avoid inline objects/arrays in JSX props
- Split context by update frequency: Don't put fast-changing data with slow-changing data
Performance optimization in React is about understanding the framework's rendering model and working with it, not against it. Every optimization adds complexity—invest in the ones that actually move the needle for your users.