Key Takeaways
- Always measure before optimizing using React DevTools Profiler to identify real bottlenecks
- React.memo, useMemo, and useCallback should be used selectively for expensive components, not applied everywhere
- Code splitting by route and feature reduces initial bundle size by 40-60% for typical applications
- Virtualization reduces DOM nodes from thousands to 20-30 for long lists, eliminating scroll jank
- Performance budgets integrated into CI/CD prevent regressions from reaching production
Measurement-First Optimization Strategy
The most common React performance mistake is optimizing without measuring. Developers add React.memo, useMemo, and useCallback everywhere based on blog posts rather than actual performance data. This adds complexity without meaningful improvement.
| Tool | What It Measures | When to Use |
|---|---|---|
| React DevTools Profiler | Component render times and frequency | Identifying slow components |
| Chrome Performance tab | Main thread blocking, layout thrashing | Diagnosing jank and freezes |
| Lighthouse | LCP, FID, CLS, TTI scores | Overall page performance audit |
| vite-plugin-visualizer | Bundle composition and size | Finding large dependencies |
| Web Vitals API | Real user metrics (RUM) | Production performance monitoring |
React DevTools Profiler results in development mode are misleading. Development mode includes extra checks that slow rendering. Always profile using production builds with React's profiling build enabled.
Strategic Memoization: When and Where
Memoization prevents unnecessary recalculations and re-renders. However, every memoization hook has a cost: memory allocation and comparison logic. Use memoization only when the cost of re-rendering exceeds the cost of memoizing.
| Hook | Purpose | Use When | Avoid When |
|---|---|---|---|
| React.memo | Skip re-render if props unchanged | Expensive renders, stable props | Cheap components, frequently changing props |
| useMemo | Cache computed values | Expensive calculations (sort, filter) | Simple calculations, primitive values |
| useCallback | Cache function references | Functions passed to memoized children | Functions not passed as props |
- Profile first: only memoize components that show up as bottlenecks in the Profiler
- React.memo is most effective for components with complex render trees and stable props
- useMemo for sorting, filtering, or transforming large arrays (1000+ items)
- useCallback is only useful when the receiving component is wrapped in React.memo
- Consider useRef for values that should persist without triggering re-renders
Wrapping every component in React.memo and every function in useCallback is an anti-pattern. The shallow comparison cost of memo can exceed the cost of simply re-rendering cheap components. Measure, do not assume.
Code Splitting and Lazy Loading
Code splitting reduces the initial JavaScript bundle that users must download and parse before the page becomes interactive. Route-based splitting is the highest-impact optimization for most applications.
- Split by route: wrap each page component in React.lazy() with a Suspense fallback
- Split heavy features: lazy-load chart libraries, rich text editors, and PDF viewers
- Preload critical routes: use dynamic import() on hover or route proximity for instant transitions
- Analyze results: use bundle visualizer to verify splits reduce the main chunk significantly
- Set budgets: enforce maximum chunk sizes (e.g., 250KB gzipped) in CI/CD
Vite automatically splits vendor chunks. Configure `build.rollupOptions.output.manualChunks` to further optimize splitting. Group related vendor libraries (e.g., all charting deps) into named chunks for better caching.
State Architecture for Performance
State placement directly impacts which components re-render and how often. Poor state architecture causes cascading re-renders across the component tree, degrading performance even in small applications.
| State Pattern | Re-render Scope | Best For |
|---|---|---|
| Local useState | Single component | Form inputs, UI toggles, animations |
| Lifted state | Parent + children | Shared form state, sibling communication |
| React Context | All consumers | Theme, auth, locale (infrequent changes) |
| Zustand/Jotai | Selected subscribers | Frequent global updates, complex state |
| TanStack Query | Query-specific | Server state, caching, synchronization |
- Keep state as close to where it is used as possible
- Avoid putting frequently changing values in React Context (every consumer re-renders)
- Split Context into separate providers for unrelated data (AuthContext, ThemeContext, not AppContext)
- Use TanStack Query for server state instead of manually managing loading/error/data states
- Consider Zustand or Jotai for global client state that changes frequently
If a piece of state is only used by one component, keep it in that component. If used by siblings, lift to their parent. Only reach for global state management when state is truly shared across distant parts of the tree.
Frequently Asked Questions
Related services from Verbsz Technologies
If this article is relevant to a project on your roadmap, these are the services our team typically delivers.
