React Large Table Performance: Practical Optimization Guide

React Large Table Performance: Practical Optimization Guide

A practical, code-first guide to optimizing large table rendering in React. Covers virtualization, memoization, stable keys, pagination, and server-side data fetching with real before-and-after examples.

frontend
August 13, 2026
14 min read

You built a data table in React. It works great with 50 rows. Then your client loads 5,000 rows and the browser freezes for three seconds. Sound familiar? Rendering large tables is one of the most common React performance pitfalls — and one of the most fixable. This guide skips the theory padding and goes straight to practical techniques: virtualization, memoized rows, stable keys, pagination, and server-side data fetching. Every section follows a short theory → code example → explanation pattern so you can apply each fix immediately.

Why Rendering Thousands of Rows Kills Performance

When React renders a table, it creates a real DOM node for every single row and cell. A table with 10,000 rows and 8 columns produces 80,000+ DOM nodes. The browser must calculate layout, paint pixels, and keep all those nodes in memory — even the ones you cannot see. This is called an "oversized DOM" and it causes three cascading problems:

  • Initial render is slow — React must reconcile and mount every node at once.
  • Scrolling is janky — the browser repaints a massive layout on every scroll event.
  • Any state change re-renders the whole list — even if only one row changed.

Here is the naive approach that causes the problem:

typescript
// ❌ BEFORE — renders every row, every time
function NaiveTable({ rows }: { rows: Row[] }) {
  return (
    <table>
      <tbody>
        {rows.map((row, index) => (
          <tr key={index}> {/* ⚠️ index as key — also bad */}
            <td>{row.id}</td>
            <td>{row.name}</td>
            <td>{row.email}</td>
            <td>{row.status}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

With 10,000 rows, this component mounts 10,000 <tr> elements and 40,000 <td> elements into the DOM at once. Every parent state change will also re-evaluate this entire list. Let's fix that step by step.

Stable Keys and Memoized Rows: The Cheapest Wins

Before reaching for a virtualization library, grab two free wins: stable keys and memoized row components. These alone can cut unnecessary re-renders dramatically.

Stable keys tell React exactly which DOM node maps to which data item. Using an array index as a key means React cannot tell the difference between a row that moved and a row that changed — so it re-renders everything. Use a unique, stable ID from your data instead.

Memoized rows wrap each row component in React.memo. When the parent re-renders (e.g., a filter input changes), only rows whose props actually changed will re-render. Rows with unchanged data are skipped entirely.

typescript
import React, { memo } from 'react';

type Row = { id: string; name: string; email: string; status: string };

// ✅ Memoized row — only re-renders when its own data changes
const TableRow = memo(function TableRow({ row }: { row: Row }) {
  return (
    <tr>
      <td>{row.id}</td>
      <td>{row.name}</td>
      <td>{row.email}</td>
      <td>{row.status}</td>
    </tr>
  );
});

// ✅ Stable key from row.id, not the array index
function OptimizedTable({ rows }: { rows: Row[] }) {
  return (
    <table>
      <tbody>
        {rows.map((row) => (
          <TableRow key={row.id} row={row} />
        ))}
      </tbody>
    </table>
  );
}

With React.memo, if you update a single row's status, only that one <TableRow> re-renders. The other 9,999 rows are skipped. This is a zero-dependency optimization you can apply right now.

Rule of thumb: always use a stable, unique ID as the React key for list items. Never use the array index unless the list is static and never reordered.

Pagination vs Virtualization: Choosing the Right Tool

Both pagination and virtualization reduce the number of DOM nodes at any given time, but they work differently and suit different use cases.

FeaturePaginationVirtualization
DOM nodes at onceOnly current page (e.g., 50 rows)Only visible rows (~10–20)
User experienceExplicit page navigationSeamless infinite scroll
Implementation complexityLowMedium
Best forReports, admin tables, SEO contentLive feeds, huge datasets, dashboards
Server-side friendlyYes — fetch one page at a timeYes — with infinite query
AccessibilityEasier to implement correctlyRequires extra ARIA work

Use pagination when users need to navigate to a specific page, share a URL, or when the dataset is server-side. Use virtualization when you want a smooth scroll experience over a very large local dataset or a streaming feed.

Client-Side Pagination: Simple and Effective

Client-side pagination loads all data once, then slices it into pages. It is the simplest optimization and works well for datasets under ~5,000 rows.

typescript
import { useState, useMemo } from 'react';

const PAGE_SIZE = 50;

function PaginatedTable({ allRows }: { allRows: Row[] }) {
  const [page, setPage] = useState(0);

  // useMemo avoids re-slicing on every unrelated render
  const visibleRows = useMemo(
    () => allRows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
    [allRows, page]
  );

  const totalPages = Math.ceil(allRows.length / PAGE_SIZE);

  return (
    <div>
      <table>
        <tbody>
          {visibleRows.map((row) => (
            <TableRow key={row.id} row={row} />
          ))}
        </tbody>
      </table>

      <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
        <button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}>
          Previous
        </button>
        <span>Page {page + 1} of {totalPages}</span>
        <button onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page === totalPages - 1}>
          Next
        </button>
      </div>
    </div>
  );
}

Key details: useMemo caches the sliced array so it is only recalculated when allRows or page changes. The TableRow component is still memoized, so switching pages only mounts the new 50 rows and unmounts the old 50 — not all 5,000.

Server-Side Pagination: Scale Beyond the Browser

When your dataset has millions of rows, you cannot load it all into the browser. Server-side pagination fetches only the current page from the API. The client never holds more than one page of data at a time.

typescript
import { useState, useEffect } from 'react';

const PAGE_SIZE = 50;

type ApiResponse = { rows: Row[]; totalCount: number };

async function fetchRows(page: number, pageSize: number): Promise<ApiResponse> {
  const res = await fetch(`/api/data?page=${page}&pageSize=${pageSize}`);
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}

function ServerPaginatedTable() {
  const [page, setPage] = useState(0);
  const [data, setData] = useState<ApiResponse | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetchRows(page, PAGE_SIZE).then((result) => {
      if (!cancelled) {
        setData(result);
        setLoading(false);
      }
    });
    return () => { cancelled = true; }; // cleanup on page change
  }, [page]);

  const totalPages = data ? Math.ceil(data.totalCount / PAGE_SIZE) : 1;

  return (
    <div>
      {loading && <p>Loading...</p>}
      <table>
        <tbody>
          {data?.rows.map((row) => <TableRow key={row.id} row={row} />)}
        </tbody>
      </table>
      <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
        <button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0 || loading}>
          Previous
        </button>
        <span>Page {page + 1} of {totalPages}</span>
        <button onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1 || loading}>
          Next
        </button>
      </div>
    </div>
  );
}

The cancelled flag in the useEffect cleanup prevents a stale fetch from overwriting newer data if the user clicks pages quickly — a common race condition bug. In production, consider replacing this pattern with React Query or TanStack Query, which handle caching, deduplication, and background refetching automatically.

Table Virtualization with TanStack Virtual

Virtualization renders only the rows currently visible in the viewport. As the user scrolls, rows outside the view are unmounted and new ones are mounted. The DOM stays small regardless of dataset size. TanStack Virtual (formerly react-virtual) is the go-to library for this in React — it is headless, meaning it gives you the math and you control the markup.

Install it first:

bash
npm install @tanstack/react-virtual

Here is a complete virtualized table example with 10,000 rows:

typescript
import { useRef, useMemo, memo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';

type Row = { id: string; name: string; email: string; status: string };

const VirtualRow = memo(function VirtualRow({ row }: { row: Row }) {
  return (
    <>
      <td>{row.id}</td>
      <td>{row.name}</td>
      <td>{row.email}</td>
      <td>{row.status}</td>
    </>
  );
});

function VirtualizedTable({ rows }: { rows: Row[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: rows.length,          // total number of items
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,      // estimated row height in px
    overscan: 5,                 // render 5 extra rows above/below viewport
  });

  const virtualItems = virtualizer.getVirtualItems();
  const totalHeight = virtualizer.getTotalSize(); // total scrollable height

  return (
    // Scrollable container — must have a fixed height
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr>
            <th>ID</th><th>Name</th><th>Email</th><th>Status</th>
          </tr>
        </thead>
        <tbody>
          {/* Spacer row to maintain correct scroll height */}
          <tr style={{ height: virtualItems[0]?.start ?? 0 }} />

          {virtualItems.map((virtualItem) => (
            <tr key={rows[virtualItem.index].id} style={{ height: 40 }}>
              <VirtualRow row={rows[virtualItem.index]} />
            </tr>
          ))}

          {/* Bottom spacer */}
          <tr style={{ height: totalHeight - (virtualItems.at(-1)?.end ?? 0) }} />
        </tbody>
      </table>
    </div>
  );
}

What's happening here: useVirtualizer calculates which items are in view based on the scroll position of parentRef. It returns only those virtual items (typically 15–25 rows). Two spacer <tr> elements — one at the top and one at the bottom — maintain the correct total scroll height so the scrollbar behaves naturally. The overscan: 5 option pre-renders 5 rows beyond the viewport edge to prevent blank flashes during fast scrolling.

Before vs After: Performance Comparison

Here is a realistic comparison of the same 10,000-row dataset rendered with different strategies. Numbers are representative of a mid-range laptop running Chrome DevTools Performance profiling.

StrategyInitial Render TimeDOM NodesScroll FPSRe-render on Filter Change
Naive (no optimization)~3,200 ms~80,000+12–18 fps~3,000 ms
Stable keys + React.memo~3,100 ms~80,000+15–20 fps~80 ms (only changed rows)
Client-side pagination (50/page)~18 ms~20060 fps~18 ms
TanStack Virtual (all 10k rows)~35 ms~200–40060 fps~35 ms
Server-side pagination~20 ms (+ network)~20060 fps~20 ms (+ network)

The key insight: memoization alone does not fix the initial render or scroll performance because all 80,000 DOM nodes still exist. Pagination and virtualization fix the root cause — too many DOM nodes — while memoization fixes unnecessary re-renders after the initial mount.

Avoiding Unnecessary Calculations in Table Rendering

Even with virtualization, you can still waste CPU cycles inside each row. Here are the most common culprits and how to fix them.

1. Inline object and function props — Creating new objects or functions inside JSX breaks React.memo because the reference changes on every render.

typescript
// ❌ BAD — new function reference on every render, breaks memo
{rows.map((row) => (
  <TableRow
    key={row.id}
    row={row}
    onSelect={() => handleSelect(row.id)} // new function every render!
  />
))}

// ✅ GOOD — stable callback with useCallback
const handleSelect = useCallback((id: string) => {
  setSelectedId(id);
}, []); // empty deps = created once

{rows.map((row) => (
  <TableRow key={row.id} row={row} onSelect={handleSelect} />
))}

2. Expensive derived data computed in render — If you sort or filter inside the component body, it runs on every render. Move it into useMemo.

typescript
// ❌ BAD — sorts 10,000 rows on every render
function Table({ rows, sortKey }: { rows: Row[]; sortKey: keyof Row }) {
  const sorted = [...rows].sort((a, b) =>
    String(a[sortKey]).localeCompare(String(b[sortKey]))
  );
  // ...
}

// ✅ GOOD — only re-sorts when rows or sortKey changes
function Table({ rows, sortKey }: { rows: Row[]; sortKey: keyof Row }) {
  const sorted = useMemo(
    () => [...rows].sort((a, b) =>
      String(a[sortKey]).localeCompare(String(b[sortKey]))
    ),
    [rows, sortKey]
  );
  // ...
}

3. Context that changes too often — If your table rows consume a React context that updates frequently (e.g., a global theme or user object), every context update re-renders every row even with React.memo. Split your context into stable and volatile parts, or pass only the needed values as props.

When to Use TanStack Virtual vs Other Solutions

TanStack Virtual is not always the right choice. Here is a decision guide:

  • Use TanStack Virtual when: you have 1,000+ rows, need smooth infinite scroll, and want full control over markup and styling.
  • Use TanStack Table (with Virtual) when: you also need sorting, filtering, column resizing, and row selection built in.
  • Use react-window when: you need a simpler API and fixed-size rows — it has a smaller bundle and less configuration.
  • Use pagination (client or server) when: your users expect page navigation, you need SEO-indexable content, or your dataset is server-side.
  • Use nothing extra when: your table has fewer than 200–300 rows — the overhead of a virtualization library is not worth it at that scale.

Common Mistakes to Avoid

  1. Using array index as key — causes incorrect reconciliation when rows are added, removed, or reordered.
  2. Skipping React.memo on row components — every parent state change re-renders all rows.
  3. Forgetting useMemo for sort/filter logic — expensive operations run on every render.
  4. Creating inline callbacks in JSX — breaks memoization silently.
  5. Not setting a fixed height on the virtualizer container — TanStack Virtual requires a scrollable element with a known height.
  6. Virtualizing small lists — adds complexity with no benefit under ~300 rows.
  7. Ignoring the overscan prop — too low causes blank rows during fast scroll; too high wastes render cycles.
  8. Fetching all data then paginating client-side for million-row datasets — the initial fetch alone will crash the browser tab.

Frequently Asked Questions

Q: How many rows is 'too many' for a plain React table? A: There is no hard rule, but performance typically degrades noticeably above 500–1,000 rows on average hardware. Apply memoization first; add virtualization or pagination above 1,000 rows.

Q: Can I use TanStack Virtual with TanStack Table? A: Yes — they are designed to work together. TanStack Table handles data logic (sorting, filtering, pagination) and TanStack Virtual handles DOM rendering. The TanStack docs include a combined example.

Q: Does React.memo do a deep comparison of props? A: No. By default it does a shallow comparison (reference equality). If you pass a new object or array reference with the same values, the row will still re-render. Use useMemo to stabilize object references passed as props.

Q: Should I use virtualization with server-side pagination? A: Usually not both at once. Server-side pagination already limits DOM nodes to one page. Virtualization is most useful when you load a large dataset client-side and want seamless scrolling. Combining both adds complexity without proportional benefit.

Q: My virtualized table has blank rows during fast scroll. How do I fix it? A: Increase the overscan value in useVirtualizer (try 10–15). Also ensure your estimateSize is close to the actual rendered row height — large discrepancies cause the virtualizer to miscalculate positions.

Q: Is react-window still maintained? A: react-window is stable but not actively developed. TanStack Virtual is the actively maintained successor and supports both fixed and variable row heights out of the box.

Next Steps and Summary

Here is the optimization checklist in order of effort vs impact:

  1. Replace index keys with stable unique IDs — zero effort, immediate correctness improvement.
  2. Wrap row components in React.memo — one line of code, eliminates cascading re-renders.
  3. Move sort/filter logic into useMemo — prevents expensive recalculations on every render.
  4. Stabilize callbacks with useCallback — keeps memoized rows from re-rendering due to new function references.
  5. Add client-side pagination for datasets under ~5,000 rows — simple, accessible, and fast.
  6. Switch to server-side pagination for large or growing datasets — keeps the browser lean.
  7. Add TanStack Virtual for smooth infinite-scroll UX over large local datasets.

You do not need to apply all of these at once. Start at the top of the list and profile your table in Chrome DevTools after each change. Most real-world tables are fixed by steps 1–4 alone. Reach for virtualization when you genuinely need it — it adds complexity that is only worth paying when the dataset and UX demand it.