Frontend Engineering

React Performance Optimization at Scale

Optimize React performance with useMemo, useCallback, and code splitting. Real performance metrics and patterns from large-scale production applications.

Khalid Aboubakr
17 min read
ReactPerformanceOptimizationVirtualizationMemoizationUsememoUsecallbackCode Splitting

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:

  1. Restructure components before adding memoization
  2. Virtualize large lists rather than optimizing individual items
  3. Colocate state to minimize re-render scope
  4. Use optimistic updates for better perceived performance
  5. Code split to reduce initial bundle size
  6. Debounce/throttle expensive operations
  7. 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 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.