Performance optimization is crucial for delivering excellent user experiences. React provides many tools and patterns for building fast applications, but knowing when and how to use them is key. Let's explore the most important performance patterns.
Understanding React's Rendering Model
Before optimizing, it's essential to understand how React renders. React re-renders components when state or props change. By default, all children re-render when their parent re-renders. This is usually fine, but can become a problem in large applications.
Virtual DOM Diffing
React uses a virtual DOM to minimize actual DOM manipulations. It compares the new virtual DOM with the previous one and only applies necessary changes to the real DOM. However, the diffing process itself has a cost, and avoiding unnecessary renders is still important.
Memoization Patterns
Memoization is the most common performance optimization technique in React. It involves caching the results of expensive computations and reusing them when inputs haven't changed.
React.memo for Component Memoization
React.memo is a higher-order component that memoizes the result. If props haven't changed, React skips re-rendering the component and reuses the last rendered result.
typescriptconst ExpensiveComponent = React.memo(function ExpensiveComponent({ data }) { return <div>{/* Expensive rendering */}</div>; });
Use React.memo when: components render often with the same props, components are expensive to render, and components are pure given the same props.
Custom Comparison Functions
React.memo does a shallow comparison of props by default. For complex prop objects, you might need a custom comparison function.
typescriptconst MyComponent = React.memo(function MyComponent({ user }) { return <div>{user.name}</div>; }, (prevProps, nextProps) => { return prevProps.user.id === nextProps.user.id; });
Be careful with custom comparison functions—poorly written comparisons can hurt performance more than helping.
useMemo for Expensive Calculations
useMemo caches the result of expensive calculations. The calculation only runs when dependencies change, not on every render.
typescriptconst expensiveValue = useMemo(() => { return computeExpensiveValue(a, b); }, [a, b]);
When to Use useMemo
Use useMemo for: heavy computations that take noticeable time, calculations used in other Hooks' dependencies, and preventing object recreation for useCallback dependencies.
When Not to Use useMemo
Don't use useMemo for: simple calculations that are faster than memoization overhead, premature optimization without profiling, and every array or object creation.
useCallback for Function Stability
useCallback returns a memoized callback that only changes when dependencies change. This is useful when passing callbacks to optimized child components.
typescriptconst handleClick = useCallback(() => { doSomething(dependency); }, [dependency]);
Preventing Child Re-renders
Child components wrapped in React.memo won't re-render if their props don't change. But functions are recreated on every render, so the child would re-render anyway. useCallback solves this.
typescriptfunction Parent() { const handleClick = useCallback(() => { console.log('clicked'); }, []); return <Child onClick={handleClick} />; } const Child = React.memo(function Child({ onClick }) { return <button onClick={onClick}>Click me</button>; });
List Rendering Optimization
Rendering large lists efficiently is a common performance challenge. React provides specific patterns for optimizing list rendering.
Key Props Matter
Always provide stable, unique keys for list items. This helps React identify which items have changed, been added, or been removed.
typescript{items.map(item => ( <Item key={item.id} item={item} /> ))}
Virtual Scrolling
For very long lists (thousands of items), use virtual scrolling libraries like react-window or react-virtualized. These libraries only render visible items, dramatically improving performance.
typescriptimport { FixedSizeList } from 'react-window'; function Row({ index, style }) { return <div style={style}>Row {index}</div>; } function MyList() { return ( <FixedSizeList height={400} itemCount={1000} itemSize={35} width={300} > {Row} </FixedSizeList> ); }
Code Splitting
Code splitting reduces the initial JavaScript bundle size by splitting code into smaller chunks that load on demand.
React.lazy for Component Code Splitting
React.lazy lets you dynamically import components. Combine it with Suspense to show loading state while the component loads.
typescriptconst HeavyComponent = React.lazy(() => import('./HeavyComponent')); function App() { return ( <Suspense fallback={<Loading />}> <HeavyComponent /> </Suspense> ); }
Route-Based Splitting
Split code at the route level for the biggest impact. Each route loads only when needed.
typescriptconst Home = React.lazy(() => import('./routes/Home')); const About = React.lazy(() => import('./routes/About'));
State Management Optimization
How you structure and update state affects performance significantly.
State Colocation
Keep state as close to where it's used as possible. Avoid lifting state higher than necessary. This prevents unnecessary re-renders of components that don't need the state.
Derived State
Don't store derived state in React state. Calculate it from existing state during render instead.
typescript// ❌ Bad: Storing derived state const [items, setItems] = useState([]); const [itemCount, setItemCount] = useState(0); useEffect(() => { setItemCount(items.length); }, [items]); // ✅ Good: Calculate derived state const [items, setItems] = useState([]); const itemCount = items.length;
State Normalization
For complex state like lists of items, normalize your data structure. Store items in an object keyed by ID and store only IDs in arrays.
Context Optimization
Context can cause performance issues if not used carefully, as all consumers re-render when context value changes.
Split Contexts
Split contexts by how frequently values change. Separate static values from dynamic ones to prevent unnecessary re-renders.
typescriptconst StaticContext = createContext(staticValue); const DynamicContext = createContext(dynamicValue);
Memoize Context Values
Memoize context values to prevent unnecessary consumer re-renders.
typescriptconst value = useMemo(() => ({ user, logout }), [user]);
Image Optimization
Images are often the heaviest assets on a page. Optimize them to improve load times.
Lazy Loading Images
Lazy load images below the fold using native lazy loading or libraries.
typescript<img src="image.jpg" loading="lazy" alt="Description" />
Modern Image Formats
Use modern image formats like WebP and AVIF for better compression. Serve fallback formats for browsers that don't support them.
Profiling and Measurement
Never optimize without measuring. React provides tools to identify performance bottlenecks.
React Profiler
Use React DevTools Profiler to identify slow components. It shows which components rendered and why.
Performance API
Use the browser's Performance API to measure specific operations.
typescriptconst start = performance.now(); // ... operation const duration = performance.now() - start; console.log(`Operation took ${duration}ms`);
Common Performance Pitfalls
Avoid these common mistakes: memoizing everything without profiling, creating objects in render body without useMemo, inline functions in render without useCallback, updating state in useEffect without proper dependencies, and overusing Context for everything.
Conclusion
React performance optimization is about understanding the rendering model and applying the right patterns at the right time. Start by profiling to identify actual bottlenecks, then apply targeted optimizations. Remember that premature optimization is the root of all evil—measure first, optimize second.
Use memoization strategically, split code intelligently, and structure your state carefully. These patterns will help you build React applications that are fast, responsive, and provide excellent user experiences.