React Performance Optimization at Scale
Optimize React performance with useMemo, useCallback, and code splitting. Real performance metrics and patterns from large-scale production applications.
Introduction
Every React developer knows about useMemo, useCallback, and React.memo. But when your application grows to handle thousands of items, complex real-time updates, and demanding enterprise requirements, these basics aren't enough.
This article covers the advanced optimization techniques I've applied in healthcare dashboards, government portals, and enterprise applications where performance directly impacts user productivity.
Understanding React's Rendering Behavior
Before optimizing, understand why React re-renders:
// Every state change triggers a render of this component AND all children function ParentComponent() { const [count, setCount] = useState(0); const [user, setUser] = useState<User | null>(null); // Problem: ExpensiveChild re-renders when count changes, // even though it only depends on user return ( <div> <button onClick={() => setCount(c => c + 1)}>Count: {count}</button> <ExpensiveChild user={user} /> </div> ); }
Technique 1: Component Composition Over Memoization
Often, restructuring components eliminates the need for memoization:
// ❌ Common approach: memoize the child const MemoizedExpensiveChild = React.memo(ExpensiveChild); function ParentComponent() { const [count, setCount] = useState(0); const [user, setUser] = useState<User | null>(null); return ( <div> <button onClick={() => setCount(c => c + 1)}>Count: {count}</button> <MemoizedExpensiveChild user={user} /> </div> ); } // ✅ Better approach: lift state down or compose function ParentComponent() { const [user, setUser] = useState<User | null>(null); return ( <div> <Counter /> {/* Count state lives here now */} <ExpensiveChild user={user} /> </div> ); } function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>; }
Technique 2: Virtualization for Large Lists
Rendering thousands of items is a common performance killer:
import { useVirtual } from '@tanstack/react-virtual'; function VirtualizedList({ items }: { items: DataItem[] }) { const parentRef = useRef<HTMLDivElement>(null); const virtualizer = useVirtual({ size: items.length, parentRef, estimateSize: useCallback(() => 50, []), overscan: 5, // Render 5 extra items above/below viewport }); return ( <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}> <div style={{ height: virtualizer.totalSize, position: 'relative' }}> {virtualizer.virtualItems.map(virtualItem => ( <div key={virtualItem.key} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: virtualItem.size, transform: `translateY(${virtualItem.start}px)`, }} > <ListItem item={items[virtualItem.index]} /> </div> ))} </div> </div> ); }
Variable Height Items
function VirtualizedListWithVariableHeights({ items }: { items: DataItem[] }) { const parentRef = useRef<HTMLDivElement>(null); const measurementCache = useRef<Map<number, number>>(new Map()); const virtualizer = useVirtual({ size: items.length, parentRef, estimateSize: useCallback((index: number) => { return measurementCache.current.get(index) ?? 50; }, []), measureRef: useCallback((el: HTMLElement | null) => { if (el) { const index = parseInt(el.dataset.index || '0'); measurementCache.current.set(index, el.getBoundingClientRect().height); } }, []), }); return ( <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}> <div style={{ height: virtualizer.totalSize, position: 'relative' }}> {virtualizer.virtualItems.map(virtualItem => ( <div key={virtualItem.key} ref={virtualizer.measureRef} data-index={virtualItem.index} style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${virtualItem.start}px)`, }} > <VariableHeightItem item={items[virtualItem.index]} /> </div> ))} </div> </div> ); }
Technique 3: State Colocation
Keep state as close as possible to where it's used:
// ❌ Global state for everything const useGlobalStore = create<GlobalState>((set) => ({ user: null, theme: 'light', sidebarOpen: false, currentPage: 1, searchQuery: '', selectedFilters: [], sortOrder: 'asc', // ... 20 more properties })); // Every component subscribes to entire store, re-renders on any change // ✅ Split into focused stores/contexts const useAuthStore = create<AuthState>((set) => ({ user: null, login: async (credentials) => { /* ... */ }, logout: () => set({ user: null }), })); const useUIStore = create<UIState>((set) => ({ theme: 'light', sidebarOpen: false, toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })), })); // Component only subscribes to what it needs function Sidebar() { const sidebarOpen = useUIStore(s => s.sidebarOpen); // Only re-renders when sidebarOpen changes }
Technique 4: Optimistic Updates
Don't make users wait for server responses:
function useOptimisticMutation<T>( mutationFn: (data: T) => Promise<T>, options: { onMutate: (data: T) => T | undefined; onError: (error: Error, data: T, context: T | undefined) => void; onSettled: () => void; } ) { const [isPending, setIsPending] = useState(false); const mutate = useCallback(async (data: T) => { setIsPending(true); // Optimistically update const previousData = options.onMutate(data); try { await mutationFn(data); } catch (error) { // Rollback on error options.onError(error as Error, data, previousData); } finally { setIsPending(false); options.onSettled(); } }, [mutationFn, options]); return { mutate, isPending }; } // Usage function TodoList() { const [todos, setTodos] = useState<Todo[]>([]); const { mutate: toggleTodo } = useOptimisticMutation( (todo: Todo) => api.updateTodo({ ...todo, completed: !todo.completed }), { onMutate: (todo) => { const previous = todos; setTodos(todos.map(t => t.id === todo.id ? { ...t, completed: !t.completed } : t )); return previous; }, onError: (error, todo, previousTodos) => { if (previousTodos) setTodos(previousTodos); toast.error('Failed to update todo'); }, onSettled: () => queryClient.invalidateQueries(['todos']), } ); }
Technique 5: Code Splitting and Lazy Loading
Load code only when needed:
// Route-based splitting const Dashboard = lazy(() => import('./pages/Dashboard')); const Reports = lazy(() => import('./pages/Reports')); const Settings = lazy(() => import('./pages/Settings')); function App() { return ( <Suspense fallback={<PageLoader />}> <Routes> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/reports" element={<Reports />} /> <Route path="/settings" element={<Settings />} /> </Routes> </Suspense> ); } // Component-based splitting for heavy features const HeavyChartLibrary = lazy(() => import('./components/Charts')); function ReportPage() { const [showCharts, setShowCharts] = useState(false); return ( <div> <button onClick={() => setShowCharts(true)}>Show Charts</button> {showCharts && ( <Suspense fallback={<ChartSkeleton />}> <HeavyChartLibrary data={reportData} /> </Suspense> )} </div> ); }
Technique 6: Debouncing and Throttling
Control the rate of expensive operations:
function useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } function SearchComponent() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300); // API call only fires after user stops typing for 300ms const { data } = useQuery( ['search', debouncedQuery], () => searchApi(debouncedQuery), { enabled: debouncedQuery.length > 2 } ); return ( <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> ); }
Profiling and Measuring
Don't optimize blindly:
// Use React DevTools Profiler // Wrap suspicious components in Profiler import { Profiler, ProfilerOnRenderCallback } from 'react'; const onRender: ProfilerOnRenderCallback = ( id, phase, actualDuration, baseDuration, startTime, commitTime ) => { // Log slow renders if (actualDuration > 16) { // More than one frame console.warn(`Slow render: ${id} took ${actualDuration.toFixed(2)}ms`); } }; function App() { return ( <Profiler id="Dashboard" onRender={onRender}> <Dashboard /> </Profiler> ); }
Conclusion
Performance optimization is about making informed trade-offs. Measure first, identify bottlenecks, then apply the appropriate technique:
- Restructure components before adding memoization
- Virtualize large lists rather than optimizing individual items
- Colocate state to minimize re-render scope
- Use optimistic updates for better perceived performance
- Code split to reduce initial bundle size
- Debounce/throttle expensive operations
- Profile continuously to catch regressions
The best optimization is often the one you don't need—simple, well-structured code is usually fast enough.
Related Articles
Frontend Engineering15 min read
State Management Architecture in Modern React
Compare React state management solutions: Redux Toolkit, Zustand, and Context API. Learn when to use each with real examples and performance considerations.
Performance & SEO19 min read
Core Web Vitals Optimization: A Technical Deep Dive
Improve Core Web Vitals scores with practical LCP, FID, and CLS optimization techniques. Real before/after metrics from production React and Next.js applications.
Frontend Engineering18 min read
Micro-Frontends: Architecture and Implementation Patterns
Build micro-frontends with Webpack Module Federation and Single-SPA. Covers team organization, shared dependencies, and production deployment patterns.
Frontend Engineering16 min read
Advanced TypeScript Patterns for Large Codebases
Master advanced TypeScript patterns including generics, utility types, and type guards. Real examples from large-scale React and Node.js codebases.
Performance & SEO16 min read
Frontend Performance Budgets: Setting and Enforcing Limits
Set and enforce performance budgets with Webpack, Lighthouse CI, and bundle analyzers. Covers automated testing, CI/CD integration, and team accountability.