Skip to content
Hiren Limbasiya
Email me
Writing

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

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 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.

tsx
// 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.

tsx
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.

tsx
// 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.

tsx
// 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 });

Written by Hiren Limbasiya, tech lead and full-stack engineer. Questions or corrections are welcome.