Advanced React Ecosystem: Performance, Concurrent Features, and Testing at Scale
Moving beyond syntax, this guide addresses the 'Day 2' challenges of React engineering. We explore deep performance optimization (virtualization, tree shaking), the architectural paradigm shift of React Server Components and Concurrent Rendering, and establish a rigorous testing pyramid using Jest, React Testing Library, and Cypress.
Performance Optimization
React.memo
React.memo is a higher-order component that memoizes functional components, preventing re-renders when props haven't changed—use it for components that render often with the same props.
const ExpensiveComponent = React.memo(function ExpensiveComponent({ data }) { // Only re-renders if `data` changes (shallow comparison) return <div>{/* complex rendering */}</div>; }); // With custom comparison const MemoizedList = React.memo(TodoList, (prevProps, nextProps) => { return prevProps.items.length === nextProps.items.length; });
Without memo: With memo:
Parent renders Parent renders
↓ ↓
Child ALWAYS renders Props changed?
↓ ↓
Yes No
↓ ↓
Render Skip!
useMemo Hook
useMemo memoizes expensive computed values, recalculating only when dependencies change—preventing costly recalculations on every render.
function ProductList({ products, filter }) { // Only recalculates when products or filter changes const filteredProducts = useMemo(() => { console.log('Filtering...'); // Won't run on every render return products.filter(p => p.category === filter); }, [products, filter]); const stats = useMemo(() => ({ total: filteredProducts.length, avgPrice: filteredProducts.reduce((a, b) => a + b.price, 0) / filteredProducts.length, }), [filteredProducts]); return <Grid items={filteredProducts} stats={stats} />; }
useCallback Hook
useCallback memoizes function references, preventing child components wrapped in React.memo from re-rendering due to new function instances being created on each parent render.
function Parent() { const [count, setCount] = useState(0); // Without useCallback: new function every render // With useCallback: same function reference const handleClick = useCallback(() => { console.log('Clicked!'); }, []); // Empty deps = never recreated const handleUpdate = useCallback((id) => { setItems(prev => prev.map(item => item.id === id ? { ...item, updated: true } : item )); }, []); // Stable reference using functional update return <MemoizedChild onClick={handleClick} />; }
Code Splitting
Code splitting breaks your bundle into smaller chunks loaded on demand, reducing initial load time by only downloading code when it's actually needed.
Without Code Splitting: With Code Splitting:
┌────────────────────┐ ┌──────────┐
│ │ │ Main │ ← Initial load
│ Single Large │ │ Bundle │
│ Bundle │ └────┬─────┘
│ (2MB) │ │
│ │ ┌────┴─────┬──────────┐
└────────────────────┘ │ │ │
Chunk A Chunk B Chunk C
(200KB) (300KB) (150KB)
↑
Loaded on demand
React.lazy
React.lazy enables dynamic importing of components, automatically code-splitting them into separate bundles that load only when the component is first rendered.
import React, { Suspense, lazy } from 'react'; // Instead of: import HeavyChart from './HeavyChart'; const HeavyChart = lazy(() => import('./HeavyChart')); const AdminPanel = lazy(() => import('./AdminPanel')); function Dashboard() { return ( <Suspense fallback={<ChartSkeleton />}> <HeavyChart data={data} /> </Suspense> ); }
Suspense
Suspense is a React component that displays fallback content while waiting for lazy-loaded components or data to load, providing a declarative way to handle loading states.
import { Suspense, lazy } from 'react'; const Comments = lazy(() => import('./Comments')); const Photos = lazy(() => import('./Photos')); function Post() { return ( <article> <PostContent /> <Suspense fallback={<CommentsSkeleton />}> <Comments /> </Suspense> <Suspense fallback={<PhotosGrid placeholder />}> <Photos /> </Suspense> </article> ); }
┌─────────────────────────────────┐
│ <Suspense> │
│ ┌───────────────────────────┐ │
│ │ Child loading? → Fallback │ │
│ │ Child ready? → Content │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
Dynamic Imports
Dynamic imports use JavaScript's import() syntax to load modules asynchronously at runtime, enabling code splitting for any module—not just React components.
// Dynamic component import const Chart = lazy(() => import('./Chart')); // Dynamic utility import async function handleExport() { const { exportToPDF } = await import('./utils/export'); exportToPDF(data); } // Dynamic import with named export const Modal = lazy(() => import('./components').then(module => ({ default: module.Modal })) ); // Prefetching const prefetchDashboard = () => import('./Dashboard'); onMouseEnter={prefetchDashboard} // Load on hover
Route-Based Splitting
Route-based code splitting loads page components only when users navigate to specific routes, naturally aligning code splitting with user navigation patterns.
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { Suspense, lazy } from 'react'; const Home = lazy(() => import('./pages/Home')); const Dashboard = lazy(() => import('./pages/Dashboard')); const Settings = lazy(() => import('./pages/Settings')); function App() { return ( <BrowserRouter> <Suspense fallback={<PageLoader />}> <Routes> <Route path="/" element={<Home />} /> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/settings" element={<Settings />} /> </Routes> </Suspense> </BrowserRouter> ); }
Component-Based Splitting
Component-based splitting lazily loads heavy components within a page based on user interaction or viewport visibility, optimizing initial page load while deferring non-critical UI.
const HeavyEditor = lazy(() => import('./HeavyEditor')); const AdvancedFilters = lazy(() => import('./AdvancedFilters')); function ProductPage() { const [showFilters, setShowFilters] = useState(false); const [isEditing, setIsEditing] = useState(false); return ( <div> <ProductInfo /> <button onClick={() => setShowFilters(true)}> Show Advanced Filters </button> {showFilters && ( <Suspense fallback={<FiltersSkeleton />}> <AdvancedFilters /> </Suspense> )} {isEditing && ( <Suspense fallback={<EditorLoading />}> <HeavyEditor /> </Suspense> )} </div> ); }
Virtualization
Virtualization renders only visible items in large lists/grids, dramatically improving performance by reducing DOM nodes from thousands to just dozens regardless of data size.
Without Virtualization: With Virtualization:
┌────────────────────┐ ┌────────────────────┐
│ Item 1 (in DOM) │ │ │ ← Buffer
│ Item 2 (in DOM) │ ├────────────────────┤
│ Item 3 (in DOM) │ │ Item 45 (in DOM) │ ↑
│ ... │ │ Item 46 (in DOM) │ │ Visible
│ Item 9998 (in DOM) │ │ Item 47 (in DOM) │ │ Viewport
│ Item 9999 (in DOM) │ │ Item 48 (in DOM) │ ↓
│ Item 10000(in DOM) │ ├────────────────────┤
└────────────────────┘ │ │ ← Buffer
10000 DOM nodes └────────────────────┘
~10 DOM nodes!
react-window
react-window is a lightweight virtualization library (part of the react-window family by Brian Vaughn) that efficiently renders large lists and grids by only mounting visible items plus a small overscan buffer.
import { FixedSizeList } from 'react-window'; function VirtualList({ items }) { const Row = ({ index, style }) => ( <div style={style} className="row"> {items[index].name} </div> ); return ( <FixedSizeList height={400} width={300} itemCount={items.length} itemSize={35} // Row height in pixels > {Row} </FixedSizeList> ); }
react-virtualized
react-virtualized is a comprehensive virtualization library offering more features than react-window—including auto-sizers, infinite loaders, multi-grids, and cell measurers—at the cost of larger bundle size.
import { List, AutoSizer } from 'react-virtualized'; function VirtualizedList({ items }) { const rowRenderer = ({ index, key, style }) => ( <div key={key} style={style}> {items[index].name} </div> ); return ( <AutoSizer> {({ height, width }) => ( <List height={height} width={width} rowCount={items.length} rowHeight={50} rowRenderer={rowRenderer} /> )} </AutoSizer> ); }
Debouncing and Throttling
Debouncing delays execution until input stops, while throttling limits execution to once per interval—both essential for optimizing search inputs, scroll handlers, and resize events.
import { useMemo, useState, useEffect } from 'react'; import debounce from 'lodash/debounce'; function SearchInput({ onSearch }) { const [value, setValue] = useState(''); // Debounce: wait 300ms after user stops typing const debouncedSearch = useMemo( () => debounce((query) => onSearch(query), 300), [onSearch] ); useEffect(() => { return () => debouncedSearch.cancel(); // Cleanup }, [debouncedSearch]); return ( <input value={value} onChange={(e) => { setValue(e.target.value); debouncedSearch(e.target.value); }} /> ); }
Debounce (300ms):
Keystrokes: H─e─l─l─o─────────────────
↓
API Call: "Hello"
Throttle (300ms):
Scroll Events: ││││││││││││││││││││││
Handler Calls: │ │ │ │
0ms 300ms 600ms 900ms
Web Workers with React
Web Workers run JavaScript in background threads, keeping the main UI thread responsive during CPU-intensive operations like data processing, complex calculations, or image manipulation.
// worker.js self.onmessage = (e) => { const result = heavyComputation(e.data); self.postMessage(result); }; // Component function DataProcessor() { const [result, setResult] = useState(null); const workerRef = useRef(null); useEffect(() => { workerRef.current = new Worker(new URL('./worker.js', import.meta.url)); workerRef.current.onmessage = (e) => setResult(e.data); return () => workerRef.current.terminate(); }, []); const process = (data) => { workerRef.current.postMessage(data); }; return <button onClick={() => process(largeData)}>Process</button>; }
useTransition for Non-Urgent Updates
useTransition marks state updates as non-urgent, allowing React to interrupt them to keep the UI responsive—perfect for filtering large lists or navigating between tabs without blocking user input.
import { useState, useTransition } from 'react'; function FilteredList({ items }) { const [filter, setFilter] = useState(''); const [isPending, startTransition] = useTransition(); const handleChange = (e) => { const value = e.target.value; // Urgent: update input immediately setFilter(value); // Non-urgent: filter list can wait startTransition(() => { setFilteredItems(items.filter(item => item.name.includes(value) )); }); }; return ( <> <input value={filter} onChange={handleChange} /> {isPending && <Spinner />} <List items={filteredItems} /> </> ); }
useDeferredValue
useDeferredValue defers updating a value until more urgent updates complete, essentially creating a "lagged" version of a value that won't block high-priority renders.
import { useState, useDeferredValue, memo } from 'react'; function Search() { const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); const isStale = query !== deferredQuery; return ( <> <input value={query} onChange={e => setQuery(e.target.value)} /> <div style={{ opacity: isStale ? 0.5 : 1 }}> <SearchResults query={deferredQuery} /> </div> </> ); } const SearchResults = memo(function SearchResults({ query }) { // Expensive render with deferred value const results = searchItems(query); // Won't block typing return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>; });
Concurrent Features
React's concurrent features allow rendering to be interrupted, enabling the UI to remain responsive during expensive updates by prioritizing urgent interactions over background work.
Traditional Rendering:
┌────────────────────────────────────┐
│████████████████████████████████████│ ← Blocks until complete
└────────────────────────────────────┘
User input blocked ────────────────→
Concurrent Rendering:
┌──────────┐ ┌─────┐ ┌──────────────┐
│██████████│ │░░░░░│ │██████████████│
└──────────┘ └─────┘ └──────────────┘
Work Handle Resume
click!
User input processed immediately!
Profiler Component
The Profiler component measures rendering performance programmatically, collecting timing data for optimization analysis or performance monitoring in production.
import { Profiler } from 'react'; function onRender(id, phase, actualDuration, baseDuration, startTime, commitTime) { console.log({ id, // "Navigation" phase, // "mount" | "update" actualDuration, // Time spent rendering (ms) baseDuration, // Estimated time without memoization startTime, // When React started rendering commitTime, // When React committed the update }); } function App() { return ( <Profiler id="Navigation" onRender={onRender}> <Navigation /> </Profiler> ); }
React DevTools Profiler
React DevTools Profiler is a browser extension feature that visually displays component render times, highlights unnecessary re-renders, and shows what triggered each update.
┌─────────────────────────────────────────────────────────┐
│ React DevTools - Profiler Tab │
├─────────────────────────────────────────────────────────┤
│ ⏺ Start Profiling [Flamegraph] [Ranked] [Timeline] │
├─────────────────────────────────────────────────────────┤
│ Commit #3 (0.8ms) │
│ ┌─────────────────────────────────────────────┐ │
│ │ App (0.1ms) │ │
│ │ ├── Header (0.05ms) ✓ Did not render │ │
│ │ ├── ProductList (0.6ms) ⚠ Rendered │ │
│ │ │ └── ProductCard x 50 (0.5ms) │ │
│ │ └── Footer (0.05ms) ✓ Did not render │ │
│ └─────────────────────────────────────────────┘ │
│ Why did this render? Props changed: (items) │
└─────────────────────────────────────────────────────────┘
Performance Monitoring
Performance monitoring uses tools like Web Vitals, React Profiler, and APM services to track real-user metrics (LCP, FID, CLS) and identify performance regressions in production.
import { useReportWebVitals } from 'next/web-vitals'; // Or use web-vitals directly import { getCLS, getFID, getLCP } from 'web-vitals'; function App() { useEffect(() => { getCLS(console.log); // Cumulative Layout Shift getFID(console.log); // First Input Delay getLCP(console.log); // Largest Contentful Paint // Send to analytics getCLS(metric => sendToAnalytics('CLS', metric.value)); }, []); return <MainApp />; }
Bundle Size Optimization
Bundle size optimization involves analyzing and reducing JavaScript bundle sizes through techniques like code splitting, tree shaking, dependency auditing, and replacing heavy libraries with lighter alternatives.
# Analyze bundle npm install --save-dev webpack-bundle-analyzer npx vite-bundle-visualizer # For Vite # Check package sizes before installing npx bundlephobia-cli lodash
// ❌ Bad: imports entire library (70KB) import _ from 'lodash'; _.debounce(fn, 300); // ✅ Good: imports only needed function (2KB) import debounce from 'lodash/debounce'; debounce(fn, 300); // ✅ Better: use native or tiny alternative import debounce from 'just-debounce-it'; // 200B
Tree Shaking
Tree shaking eliminates unused code during bundling by analyzing ES module imports/exports, automatically removing dead code paths and unused exports to reduce bundle size.
// math.js export const add = (a, b) => a + b; export const subtract = (a, b) => a - b; export const multiply = (a, b) => a * b; // Unused export const divide = (a, b) => a / b; // Unused // app.js import { add, subtract } from './math'; // Only these imported console.log(add(1, 2)); console.log(subtract(5, 3)); // After tree shaking: multiply and divide are removed from bundle!
┌───────────────────┐ Tree ┌───────────────────┐
│ ● add │ Shaking │ ● add │
│ ● subtract │ ──────────> │ ● subtract │
│ ○ multiply (dead) │ │ │
│ ○ divide (dead) │ │ │
└───────────────────┘ └───────────────────┘
Source Bundle
React 18+ Features
Concurrent Rendering
Concurrent rendering allows React to work on multiple versions of the UI simultaneously, interrupt rendering to handle high-priority updates, and discard stale work—fundamentally enabling features like useTransition and Suspense for data fetching.
// React 18 automatically uses concurrent features when you use: // - useTransition // - useDeferredValue // - Suspense for data fetching // - startTransition // Enable by using createRoot (React 18+) import { createRoot } from 'react-dom/client'; const root = createRoot(document.getElementById('root')); root.render(<App />); // Legacy mode (no concurrent features): // ReactDOM.render(<App />, document.getElementById('root'));
Automatic Batching
React 18 automatically batches multiple state updates into a single re-render, even inside promises, timeouts, and native event handlers—previously, batching only worked in React event handlers.
// React 17: Multiple re-renders setTimeout(() => { setCount(c => c + 1); // Re-render setFlag(f => !f); // Re-render }, 1000); // React 18: Single re-render (automatic batching!) setTimeout(() => { setCount(c => c + 1); // Batched setFlag(f => !f); // Batched → One render! }, 1000); // Opt-out if needed: import { flushSync } from 'react-dom'; flushSync(() => setCount(c => c + 1)); // Immediate render flushSync(() => setFlag(f => !f)); // Immediate render
React 17: React 18:
setState A → Render setState A ─┐
setState B → Render setState B ─┼→ Single Render
setState C → Render setState C ─┘
(3 renders) (1 render!)
Transitions (useTransition, startTransition)
Transitions mark state updates as non-urgent, telling React it's okay to interrupt them for more important updates like typing—keeping the UI responsive during expensive operations.
import { useTransition, startTransition } from 'react'; function TabContainer() { const [tab, setTab] = useState('home'); const [isPending, startTransition] = useTransition(); const selectTab = (nextTab) => { startTransition(() => { setTab(nextTab); // Non-urgent, can be interrupted }); }; return ( <> <TabButtons onSelect={selectTab} /> <div style={{ opacity: isPending ? 0.7 : 1 }}> {isPending && <Spinner />} <TabContent tab={tab} /> </div> </> ); } // Without hook (fire and forget): startTransition(() => { setSearchResults(filterLargeList(query)); });
Suspense Improvements
React 18 expands Suspense to work with server-side rendering (streaming SSR), enables selective hydration, and allows multiple suspense boundaries to coordinate loading states more effectively.
// Nested Suspense with coordinated loading function App() { return ( <Suspense fallback={<PageSkeleton />}> <Header /> <Suspense fallback={<SidebarSkeleton />}> <Sidebar /> </Suspense> <Suspense fallback={<ContentSkeleton />}> <MainContent /> <Suspense fallback={<CommentsSkeleton />}> <Comments /> </Suspense> </Suspense> </Suspense> ); }
Streaming Order:
┌──────────────────────────────────────┐
│ [Header loads first] │ ← Immediate
├────────────┬─────────────────────────┤
│ [Sidebar] │ [Content skeleton] │ ← Sidebar streams in
│ │ │
│ │ [Main content loads] │ ← Content streams
│ │ [Comments skeleton] │
│ │ [Comments load last] │ ← Comments stream
└────────────┴─────────────────────────┘
Streaming SSR
Streaming SSR sends HTML progressively as components render on the server, showing content to users faster instead of waiting for the entire page to render before sending anything.
// server.js (simplified) import { renderToPipeableStream } from 'react-dom/server'; app.get('/', (req, res) => { const { pipe } = renderToPipeableStream(<App />, { bootstrapScripts: ['/main.js'], onShellReady() { res.statusCode = 200; res.setHeader('Content-Type', 'text/html'); pipe(res); // Start streaming immediately! }, }); });
Traditional SSR: Streaming SSR:
[Wait...........] [Header] ───────> Browser
[Wait...........] [Nav] ───────> Browser
[Wait...........] [Content]───────> Browser
[Send all at once] [Footer] ───────> Browser
↓ ↓
Slow TTFB Fast TTFB, progressive render
Selective Hydration
Selective hydration prioritizes hydrating components users are interacting with, rather than hydrating the entire page in order—making interactive elements responsive faster.
// User clicks Comments before it's hydrated // React 18 will prioritize hydrating Comments first! <Suspense fallback={<HeavyChartSkeleton />}> <HeavyChart /> {/* Hydrates in background */} </Suspense> <Suspense fallback={<CommentsSkeleton />}> <Comments /> {/* User clicked! Hydrates first */} </Suspense>
Traditional Hydration:
[Header] → [Nav] → [Content] → [Comments] → [Footer]
↑
User clicked here,
but must wait!
Selective Hydration:
[Header] → [Comments!] → [Nav] → [Content] → [Footer]
↑
User clicked,
prioritized immediately!
useDeferredValue
useDeferredValue creates a deferred version of a value that lags behind during urgent updates, allowing expensive re-renders to be interrupted while keeping input responsive.
import { useDeferredValue, useMemo } from 'react'; function SearchResults({ query }) { // deferredQuery may lag behind query during typing const deferredQuery = useDeferredValue(query); // Expensive filtering uses deferred value const results = useMemo( () => filterLargeDataset(deferredQuery), [deferredQuery] ); // Visual feedback when stale const isStale = query !== deferredQuery; return ( <ul style={{ opacity: isStale ? 0.5 : 1 }}> {results.map(item => <li key={item.id}>{item.name}</li>)} </ul> ); }
useId
useId generates unique, stable IDs that are consistent between server and client rendering, solving hydration mismatches when generating IDs for accessibility attributes.
import { useId } from 'react'; function FormField({ label }) { const id = useId(); // e.g., ":r1:" return ( <> <label htmlFor={id}>{label}</label> <input id={id} type="text" /> </> ); } // Multiple IDs in same component function PasswordField() { const id = useId(); return ( <> <label htmlFor={`${id}-password`}>Password</label> <input id={`${id}-password`} type="password" /> <p id={`${id}-hint`}>Must be 8+ characters</p> <input aria-describedby={`${id}-hint`} /> </> ); }
useSyncExternalStore
useSyncExternalStore subscribes to external stores (Redux, Zustand, browser APIs) safely with concurrent rendering, preventing tearing where parts of UI show inconsistent data.
import { useSyncExternalStore } from 'react'; // Subscribe to browser online status function useOnlineStatus() { return useSyncExternalStore( subscribe, // How to subscribe getSnapshot, // Get current value (client) getServerSnapshot // Get value during SSR ); } function subscribe(callback) { window.addEventListener('online', callback); window.addEventListener('offline', callback); return () => { window.removeEventListener('online', callback); window.removeEventListener('offline', callback); }; } const getSnapshot = () => navigator.onLine; const getServerSnapshot = () => true; // Assume online on server function StatusBar() { const isOnline = useOnlineStatus(); return <div>{isOnline ? '✅ Online' : '❌ Offline'}</div>; }
useInsertionEffect
useInsertionEffect fires synchronously before DOM mutations, designed specifically for CSS-in-JS libraries to inject styles before any layout effects read the DOM.
import { useInsertionEffect } from 'react'; // For CSS-in-JS library authors only! function useCSS(rule) { useInsertionEffect(() => { const style = document.createElement('style'); style.textContent = rule; document.head.appendChild(style); return () => document.head.removeChild(style); }, [rule]); } // Effect execution order: // 1. useInsertionEffect ← Inject styles (before paint) // 2. useLayoutEffect ← Read layout // 3. useEffect ← After paint
┌────────────────────────────────────────────────────┐
│ Effect Timing Order │
├────────────────────────────────────────────────────┤
│ Render → useInsertionEffect → DOM Mutation │
│ ↓ │
│ useLayoutEffect │
│ ↓ │
│ Browser Paint │
│ ↓ │
│ useEffect │
└────────────────────────────────────────────────────┘
New Strict Mode Behaviors
React 18's Strict Mode double-invokes effects to help identify impure components and missing cleanup, simulating the component mounting, unmounting, and remounting—exposing bugs before they hit production.
// React 18 Strict Mode in Development: // 1. Component mounts // 2. Effects run // 3. Cleanup functions run (simulated unmount) // 4. Effects run again (simulated remount) function Component() { useEffect(() => { console.log('Effect ran'); // Logs twice in StrictMode! return () => console.log('Cleanup'); }, []); return <div>Hello</div>; } // Console output in StrictMode: // "Effect ran" // "Cleanup" // "Effect ran" // This catches bugs like missing cleanup or side effects that // aren't idempotent (safe to run multiple times)
Server Components (experimental)
React Server Components (RSC) render exclusively on the server with zero JavaScript sent to the client, enabling direct database/filesystem access while dramatically reducing bundle sizes for data-heavy components.
// ServerComponent.server.jsx - Runs ONLY on server import db from './database'; async function ServerComponent() { // Direct database access - no API needed! const products = await db.query('SELECT * FROM products'); return ( <ul> {products.map(p => <li key={p.id}>{p.name}</li>)} </ul> ); // Zero JS shipped for this component! } // ClientComponent.client.jsx - Runs on client 'use client'; function ClientComponent() { const [count, setCount] = useState(0); // Interactive, ships JavaScript return <button onClick={() => setCount(c + 1)}>{count}</button>; }
React Server Components (RSC)
RSC architecture splits components between server and client, with server components fetching data and rendering HTML while client components handle interactivity—fully implemented in Next.js 13+ App Router.
┌─────────────────────────────────────────────────────────────┐
│ RSC Architecture │
├─────────────────────────────────────────────────────────────┤
│ │
│ Server Client │
│ ┌─────────────────────┐ ┌───────────────────┐ │
│ │ ServerComponent │ ──> │ HTML (static) │ │
│ │ - Async/await │ │ No JavaScript │ │
│ │ - DB access │ │ │ │
│ │ - File system │ │ │ │
│ │ - Zero bundle │ └───────────────────┘ │
│ └─────────────────────┘ │
│ │
│ ┌─────────────────────┐ ┌───────────────────┐ │
│ │ 'use client' │ ──> │ Interactive JS │ │
│ │ ClientComponent │ │ - useState │ │
│ │ - Event handlers │ │ - useEffect │ │
│ │ - Browser APIs │ │ - Event handlers │ │
│ └─────────────────────┘ └───────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
// app/page.jsx (Next.js 13+ - Server Component by default) import { ClientButton } from './ClientButton'; async function Page() { const data = await fetch('https://api.example.com/data'); return ( <main> <h1>Server Rendered</h1> <DataDisplay data={data} /> {/* Server Component */} <ClientButton /> {/* Client Component */} </main> ); }
Testing React
Testing Philosophy
React testing follows the "Testing Trophy" philosophy: prioritize integration tests over unit tests, test behavior rather than implementation details, and write tests that resemble how users interact with your app. The guiding principle is "the more your tests resemble the way your software is used, the more confidence they give you."
/\ E2E (few)
/ \
/----\ Integration (many)
/ \
/--------\ Unit (some)
/ \
/------------\ Static (ESLint/TS)
Jest Basics
Jest is the default test runner for React, providing test suites, assertions, mocking, and code coverage out of the box with zero configuration.
describe('Calculator', () => { test('adds two numbers', () => { expect(add(2, 3)).toBe(5); }); test('throws on invalid input', () => { expect(() => add('a', 1)).toThrow(); }); });
React Testing Library
RTL provides utilities to test React components by querying the DOM the same way users would—by text, label, role—encouraging accessible and maintainable tests without testing implementation details.
import { render, screen } from '@testing-library/react'; import UserProfile from './UserProfile'; test('displays user name', () => { render(<UserProfile name="Alice" />); expect(screen.getByText('Alice')).toBeInTheDocument(); });
Rendering Components
The render function mounts your component to a virtual DOM (jsdom), returning utilities to query and interact with it; always clean up happens automatically between tests.
import { render, screen } from '@testing-library/react'; // Basic render const { container, unmount, rerender } = render(<Button label="Click" />); // Render with providers (router, theme, etc.) render(<Button />, { wrapper: ThemeProvider }); // Rerender with new props rerender(<Button label="Updated" />);
Querying Elements
RTL provides a priority-based query system: getBy (throws if not found), queryBy (returns null), and findBy (async); prefer accessible queries like ByRole, ByLabelText, ByText.
// Query Priority (best to worst) screen.getByRole('button', { name: /submit/i }) // ✅ Best screen.getByLabelText('Email') // ✅ Great screen.getByText('Welcome') // ✅ Good screen.getByTestId('submit-btn') // ⚠️ Last resort // Query types screen.getByRole('button') // throws if missing screen.queryByRole('button') // returns null if missing screen.findByRole('button') // async, waits for element
Firing Events
fireEvent dispatches DOM events to simulate user interactions; it triggers the event directly without simulating the full browser behavior chain.
import { render, screen, fireEvent } from '@testing-library/react'; test('increments counter on click', () => { render(<Counter />); const button = screen.getByRole('button', { name: /increment/i }); fireEvent.click(button); expect(screen.getByText('Count: 1')).toBeInTheDocument(); }); // Other events fireEvent.change(input, { target: { value: 'hello' } }); fireEvent.submit(form); fireEvent.keyDown(element, { key: 'Enter', code: 'Enter' });
Async Utilities (waitFor, findBy)
These utilities handle asynchronous operations: findBy queries wait for elements to appear, waitFor retries assertions until they pass or timeout, essential for testing data fetching and animations.
// findBy - combines getBy + waitFor const element = await screen.findByText('Loaded Data'); // waitFor - retry until assertion passes await waitFor(() => { expect(screen.getByText('Success')).toBeInTheDocument(); }); // waitForElementToBeRemoved await waitForElementToBeRemoved(() => screen.queryByText('Loading...')); // Configure timeout await waitFor(() => expect(mockFn).toHaveBeenCalled(), { timeout: 3000 });
User Event Library
@testing-library/user-event provides more realistic event simulation than fireEvent, accurately mimicking browser behavior including focus, keyboard events, and event sequencing.
import userEvent from '@testing-library/user-event'; test('form submission flow', async () => { const user = userEvent.setup(); render(<LoginForm />); await user.type(screen.getByLabelText('Email'), 'test@example.com'); await user.type(screen.getByLabelText('Password'), 'secret123'); await user.click(screen.getByRole('button', { name: /login/i })); // user.type triggers: focus, keyDown, keyPress, input, keyUp for EACH char // user.click triggers: pointerDown, mouseDown, pointerUp, mouseUp, click });
Testing Hooks (@testing-library/react-hooks)
For React 18+, use renderHook from @testing-library/react directly to test custom hooks in isolation, returning the hook's result and utilities to trigger updates.
import { renderHook, act } from '@testing-library/react'; import useCounter from './useCounter'; test('useCounter increments', () => { const { result } = renderHook(() => useCounter(0)); expect(result.current.count).toBe(0); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); }); // With wrapper for context const { result } = renderHook(() => useTheme(), { wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider> });
Mocking
Jest's mocking system replaces modules, functions, or timers with controlled implementations, essential for isolating components from external dependencies and testing edge cases.
// Mock a function const mockCallback = jest.fn(); mockCallback.mockReturnValue(42); mockCallback.mockResolvedValue({ data: 'async' }); // Mock timers jest.useFakeTimers(); jest.advanceTimersByTime(1000); jest.runAllTimers(); // Spy on existing methods jest.spyOn(console, 'error').mockImplementation(() => {});
Mocking Modules
jest.mock() replaces entire module imports with mock implementations, useful for isolating components from heavy dependencies like routers, stores, or utility libraries.
// __mocks__/axios.js or inline jest.mock('axios'); // Mock specific exports jest.mock('./analytics', () => ({ trackEvent: jest.fn(), initAnalytics: jest.fn(), })); // Partial mock (keep some real implementations) jest.mock('./utils', () => ({ ...jest.requireActual('./utils'), formatDate: jest.fn(() => '2024-01-01'), })); // Mock react-router jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => jest.fn(), }));
Mocking API Calls
Use Mock Service Worker (MSW) to intercept network requests at the service worker level, providing realistic API mocking that works across all fetching libraries.
// src/mocks/handlers.js import { rest } from 'msw'; export const handlers = [ rest.get('/api/users', (req, res, ctx) => { return res(ctx.json([{ id: 1, name: 'Alice' }])); }), rest.post('/api/login', async (req, res, ctx) => { const { email } = await req.json(); if (email === 'fail@test.com') { return res(ctx.status(401), ctx.json({ error: 'Invalid' })); } return res(ctx.json({ token: 'abc123' })); }), ]; // setupTests.js import { setupServer } from 'msw/node'; const server = setupServer(...handlers); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
Test Coverage
Jest collects coverage metrics showing which lines, branches, functions, and statements are executed during tests; aim for meaningful coverage (70-80%) rather than 100%.
# Run with coverage jest --coverage # Coverage report output --------------------|---------|----------|---------|---------| File | % Stmts | % Branch | % Funcs | % Lines | --------------------|---------|----------|---------|---------| All files | 85.71 | 83.33 | 77.78 | 86.96 | Button.tsx | 100 | 100 | 100 | 100 | Form.tsx | 71.43 | 50 | 66.67 | 71.43 | --------------------|---------|----------|---------|---------| # jest.config.js coverageThreshold: { global: { branches: 80, functions: 80, lines: 80 } }
Snapshot Testing
Snapshots capture a component's rendered output and compare against future runs; useful for detecting unintended UI changes but can become brittle if overused.
import renderer from 'react-test-renderer'; test('Button renders correctly', () => { const tree = renderer.create(<Button label="Click me" />).toJSON(); expect(tree).toMatchSnapshot(); }); // Inline snapshots (stored in test file) test('formats name', () => { expect(formatName('alice')).toMatchInlineSnapshot(`"Alice"`); }); // Update snapshots when intentional changes occur // jest --updateSnapshot or press 'u' in watch mode
Integration Tests
Integration tests verify multiple components working together, testing realistic user flows through your application while mocking only external boundaries (APIs, browser APIs).
test('complete checkout flow', async () => { const user = userEvent.setup(); render( <CartProvider> <Router> <App /> </Router> </CartProvider> ); // Add item to cart await user.click(screen.getByRole('button', { name: /add to cart/i })); // Navigate to checkout await user.click(screen.getByRole('link', { name: /cart/i })); expect(screen.getByText('1 item')).toBeInTheDocument(); // Complete purchase await user.click(screen.getByRole('button', { name: /checkout/i })); await screen.findByText('Order confirmed!'); });
E2E Testing with Cypress/Playwright
End-to-end tests run in real browsers, testing the complete application stack including backend integrations; slower but highest confidence for critical user journeys.
// Cypress: cypress/e2e/login.cy.js describe('Login', () => { it('logs in successfully', () => { cy.visit('/login'); cy.get('[data-testid="email"]').type('user@test.com'); cy.get('[data-testid="password"]').type('password123'); cy.get('button[type="submit"]').click(); cy.url().should('include', '/dashboard'); cy.contains('Welcome back').should('be.visible'); }); }); // Playwright: tests/login.spec.ts test('logs in successfully', async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill('user@test.com'); await page.getByLabel('Password').fill('password123'); await page.getByRole('button', { name: 'Login' }).click(); await expect(page).toHaveURL(/dashboard/); });
Component Testing
Cypress and Playwright now support mounting individual React components in isolation, combining the realism of browser testing with the speed of unit tests.
// Cypress Component Testing: Button.cy.tsx import Button from './Button'; describe('Button', () => { it('handles click events', () => { const onClickSpy = cy.spy().as('onClick'); cy.mount(<Button onClick={onClickSpy}>Click me</Button>); cy.get('button').click(); cy.get('@onClick').should('have.been.calledOnce'); }); }); // Playwright Component Testing test('button click', async ({ mount }) => { let clicked = false; const component = await mount( <Button onClick={() => clicked = true}>Click</Button> ); await component.click(); expect(clicked).toBe(true); });