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.
Frontend
Virtualizing Large React Tables and Taming Re-Render Cascades
Why a data table dies at 10,000 rows, how virtualization fixes it, and the three habits that cause re-render cascades, plus when memoization is the wrong answer.
Why Performance Matters
Memoization with React.memo, useMemo, and 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 for a 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 });