REACT PERFORMANCE PATTERNS
Memoization, virtualization, code splitting, and avoiding unnecessary re-renders in large-scale React apps.
Why Performance Matters
In production apps with complex dashboards, forms, and data tables, even small inefficiencies compound. A single unnecessary re-render in a parent component can cascade through hundreds of children. Performance isn't about premature optimization — it's about building the right mental model from the start.
Memoization — React.memo, useMemo, useCallback
React.memo prevents re-renders when props haven't changed. useMemo caches expensive computations. useCallback stabilizes function references. The key insight: don't memoize everything — profile first, then memoize the hot paths.
// Memoize an expensive config diff computation
const hasChanges = useMemo(() => {
return deepDiff(currentConfig, savedConfig);
}, [currentConfig, savedConfig]);
// Stabilize a callback passed to memoized children
const handleSelect = useCallback((id: string) => {
dispatch(selectItem(id));
}, [dispatch]);
// Prevent re-renders when props are the same
const DataRow = React.memo(({ row }: { row: Row }) => {
return <tr>{row.cells.map(cell => <td key={cell.id}>{cell.value}</td>)}</tr>;
});Virtualization for Large Lists
Rendering 10K+ rows in a data table? The DOM can't handle it. Virtualization renders only visible rows. Combined with sticky headers and dynamic row heights, you get buttery scrolling even with massive datasets.
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualTable({ rows }: { rows: Data[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // estimated row height
overscan: 10, // render 10 extra rows outside viewport
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: virtualRow.start,
height: virtualRow.size,
}}
>
<DataRow row={rows[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}Code Splitting & Lazy Loading
Use React.lazy() and dynamic imports to split your bundle by route and by feature. Heavy components like chart libraries and editors should always be lazy loaded.
// Next.js dynamic import — client-only heavy component
import dynamic from 'next/dynamic';
const VisualEditor = dynamic(() => import('./VisualEditor'), {
ssr: false,
loading: () => <EditorSkeleton />,
});
// Route-level code splitting
const DashboardPage = lazy(() => import('./pages/Dashboard'));
const SettingsPage = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
);
}Avoiding Re-Render Cascades
Common culprits: creating new objects/arrays in render, passing anonymous functions as props, and using Context for frequently-changing values. Solutions: lift state down, split contexts, and use refs for non-render values.
// BAD: new object every render → children re-render
<List style={{ marginTop: 10 }} />
// GOOD: stable reference
const listStyle = useMemo(() => ({ marginTop: 10 }), []);
<List style={listStyle} />
// Split contexts by update frequency
const LayoutConfigContext = createContext(null); // rare updates
const SelectionContext = createContext(null); // frequent updates
const UIPanelContext = createContext(null); // medium updates
// Use ref for values that don't need renders
const dragStateRef = useRef({ isDragging: false, startX: 0, startY: 0 });