Skip to main content
Back to Blog
25 August 202415 min read

React Performance: Optimization Patterns for Enterprise Applications

ReactFrontendPerformanceJavaScript

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 changes

The 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 function

Avoid 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 issues

Code 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 SizeRecommendation
< 100 itemsNo virtualization needed
100-1000 itemsConsider virtualization
> 1000 itemsVirtualization 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 made

Key Takeaways

  1. Measure first: Profile before optimizing—don't guess at bottlenecks
  2. React.memo strategically: Not everything needs memoization—it has overhead
  3. Colocate state: Global state causes global re-renders
  4. Virtualize long lists: Don't render what users can't see
  5. Split code by routes: Lazy load pages and heavy components
  6. Use stable references: Avoid inline objects/arrays in JSX props
  7. 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.

Share this article