Frontend System Design: Data Table

interviewJune 29, 2026Β· 8 min read

The data table is one of the most frequently asked frontend system design questions β€” it shows up at Meta, Google, Amazon, Bloomberg, and any company dealing with dashboards or admin panels. It looks deceptively simple ("just render rows and columns") but a production-grade answer covers sorting, filtering, pagination, virtualisation, accessibility, and performance at scale.

This article walks through a complete RADIO-framework answer. If you're unfamiliar with RADIO, start here.

R β€” Requirements

Functional requirements:

  • Render tabular data with configurable columns (label, width, data accessor, cell renderer)
  • Sort by any column (ascending / descending / none) β€” click header to toggle
  • Filter rows by a global search box, plus optional per-column filters
  • Paginate through large datasets (page size selector: 10 / 25 / 50 / 100)
  • Select rows via checkboxes (single select, multi-select, select-all-on-page)
  • Sticky header row that stays visible while scrolling

Non-functional requirements:

  • Smooth interaction with up to 100,000 rows
  • 60fps scrolling (no jank)
  • Works on desktop, tablet, and mobile (responsive column hiding)
  • Keyboard navigable (arrow keys, Enter to select, Escape to clear)
  • Screen-reader accessible (proper ARIA grid roles)

Extended requirements (if time allows):

  • Column resizing (drag column borders)
  • Column reordering (drag-and-drop headers)
  • Inline cell editing
  • Export to CSV
  • Server-side pagination / sorting / filtering

Out of scope:

  • Real-time data updates (WebSockets)
  • Complex pivot / cross-tab aggregation

A β€” Architecture / High-Level Design

At a high level, the table is split into three layers:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Table Component                β”‚
β”‚                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚  State Mgr   β”‚  β”‚     Data Layer      β”‚  β”‚
β”‚  β”‚  (sort,      β”‚  β”‚  (fetch, paginate,  β”‚  β”‚
β”‚  β”‚   filter,    β”‚  β”‚   filter, cache)    β”‚  β”‚
β”‚  β”‚   selection) β”‚  β”‚                     β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚         β”‚                     β”‚             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚           Render Layer                 β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚  β”‚
β”‚  β”‚  β”‚ Header  β”‚ β”‚ Body   β”‚ β”‚ Paginationβ”‚  β”‚  β”‚
β”‚  β”‚  β”‚ (sticky)β”‚ β”‚(virtual)β”‚ β”‚  Footer  β”‚  β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key design decisions:

  1. Controlled vs uncontrolled state β€” The table manages its own state internally but exposes a callback API (onSortChange, onFilterChange, onRowSelect) so the parent can observe or override. This is the pattern used by AG Grid, TanStack Table, and MUI DataGrid.

  2. Client-side vs server-side β€” For datasets under ~50K rows, do everything client-side. Beyond that, move sorting/filtering/pagination to the server and fetch pages on demand.

  3. Virtualisation strategy β€” Only render the rows visible in the viewport (plus a small overscan buffer). This keeps DOM node count low regardless of dataset size.

D β€” Data Model

interface Column<T> {
  id: string;
  header: string;
  accessor: (row: T) => string | number;
  width?: number;          // px
  minWidth?: number;       // default 80
  sortable?: boolean;      // default true
  filterable?: boolean;    // default true
  render?: (value: unknown, row: T) => React.ReactNode;
  align?: 'left' | 'center' | 'right';
  hiddenOnMobile?: boolean;
}

interface TableState<T> {
  data: T[];
  columns: Column<T>[];

  // Sorting
  sortColumn: string | null;
  sortDirection: 'asc' | 'desc' | null;

  // Filtering
  globalFilter: string;
  columnFilters: Record<string, string>;

  // Pagination
  page: number;            // 0-indexed
  pageSize: number;

  // Selection
  selectedRowIds: Set<string>;

  // Virtualisation
  scrollTop: number;
  viewportHeight: number;
  rowHeight: number;       // fixed, e.g. 48px
}

The accessor function is the core abstraction β€” it decouples how data is read from how it's displayed. This means the same column can sort, filter, and render without duplicating logic.

I β€” Implementation Deep-Dive

1. Sorting

Sorting is the first thing an interviewer will ask you to implement. The key question is: where does sorting happen in the pipeline?

Raw Data β†’ Filter β†’ Sort β†’ Paginate β†’ Virtualise β†’ Render

Sorting must happen after filtering (so we only sort visible rows) and before pagination (so we paginate the sorted set).

function sortData(data, sortColumn, sortDirection) {
  if (!sortColumn || !sortDirection) return data;

  return [...data].sort((a, b) => {
    const column = columns.find(c => c.id === sortColumn);
    if (!column) return 0;

    const aValue = column.accessor(a);
    const bValue = column.accessor(b);

    // Handle mixed types (string vs number)
    if (typeof aValue === 'number' && typeof bValue === 'number') {
      return sortDirection === 'asc' ? aValue - bValue : bValue - aValue;
    }

    const comparison = String(aValue).localeCompare(String(bValue));
    return sortDirection === 'asc' ? comparison : -comparison;
  });
}

Tri-state sort cycle: Click a header β†’ ascending β†’ descending β†’ no sort β†’ ascending. This "no sort" state matters β€” users want to return to the original order.

Multi-column sort: If the interviewer asks for it, maintain an array of { column, direction } objects. Apply sorts left-to-right (stable sort in reverse order).

2. Filtering

A global search filter checks every column's accessor value against the query. Per-column filters give finer control.

function filterData(data, columns, globalFilter, columnFilters) {
  let result = data;

  // Global filter β€” check all columns
  if (globalFilter) {
    const query = globalFilter.toLowerCase();
    result = result.filter(row =>
      columns.some(col => {
        const value = col.accessor(row);
        return String(value).toLowerCase().includes(query);
      })
    );
  }

  // Per-column filters
  for (const [colId, filterValue] of Object.entries(columnFilters)) {
    if (!filterValue) continue;
    const col = columns.find(c => c.id === colId);
    if (!col) continue;

    const query = filterValue.toLowerCase();
    result = result.filter(row =>
      String(col.accessor(row)).toLowerCase().includes(query)
    );
  }

  return result;
}

Performance tip: For large datasets, debounce the filter input by 200–300ms. Don't re-filter on every keystroke.

3. Pagination

Pagination is straightforward but has a few gotchas:

function paginate(data, page, pageSize) {
  const startIndex = page * pageSize;
  return data.slice(startIndex, startIndex + pageSize);
}

function totalPages(filteredCount, pageSize) {
  return Math.ceil(filteredCount / pageSize);
}

Gotcha: Reset page on filter/sort change. When the user types in the filter box, reset to page 0 β€” otherwise they might see an empty page because their current page no longer exists in the filtered set.

function onFilterChange(newFilter) {
  setGlobalFilter(newFilter);
  setPage(0);  // ← critical
}

Server-side pagination: Send ?page=2&pageSize=25&sortBy=name&sortDir=asc&search=john to the backend. The backend returns { data: [...], total: 4500 }. The table renders the page and computes total pages from total.

4. Virtualisation (The Hard Part)

This is what separates a toy table from a production one. With 100K rows, rendering all of them in the DOM would crash the browser. Virtualisation means rendering only the ~20–40 rows visible in the scroll viewport.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     (invisible spacer)    β”‚  ← paddingTop = startIndex * rowHeight
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  Row 847                  β”‚  ← visible
β”‚  Row 848                  β”‚  ← visible
β”‚  Row 849                  β”‚  ← visible
β”‚  ...                      β”‚
β”‚  Row 870                  β”‚  ← visible + overscan
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚     (invisible spacer)    β”‚  ← paddingBottom = (total - endIndex) * rowHeight
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
function useVirtualiser({ rowCount, rowHeight, viewportHeight, scrollTop, overscan = 5 }) {
  const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
  const visibleCount = Math.ceil(viewportHeight / rowHeight) + overscan * 2;
  const endIndex = Math.min(rowCount, startIndex + visibleCount);

  const visibleRows = Array.from(
    { length: endIndex - startIndex },
    (_, i) => startIndex + i
  );

  const totalHeight = rowCount * rowHeight;
  const offsetY = startIndex * rowHeight;

  return { visibleRows, totalHeight, offsetY };
}

The scroll container:

function VirtualisedBody({ rows, rowHeight, totalHeight, offsetY, renderRow }) {
  return (
    <div
      onScroll={handleScroll}
      style={{ height: viewportHeight, overflowY: 'auto', position: 'relative' }}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {rows.map(rowIndex => renderRow(rowIndex))}
        </div>
      </div>
    </div>
  );
}

Why transform: translateY instead of paddingTop? Transforms are GPU-accelerated and don't trigger layout recalculation. paddingTop forces a reflow.

ResizeObserver for viewport height: The viewport height isn't static β€” users resize windows. Use ResizeObserver to track the scroll container's height and feed it back into the virtualiser.

5. Row Selection

Selection state lives in a Set<string> of row IDs. The "select all" checkbox in the header has three states:

  1. Unchecked β€” no rows on the current page are selected
  2. Checked β€” all rows on the current page are selected
  3. Indeterminate β€” some but not all rows on the current page are selected
function getSelectAllState(visibleRowIds, selectedIds) {
  const visibleSelected = visibleRowIds.filter(id => selectedIds.has(id));

  if (visibleSelected.length === 0) return 'unchecked';
  if (visibleSelected.length === visibleRowIds.length) return 'checked';
  return 'indeterminate';
}

Select-all across pages: If the interviewer asks whether "select all" means all rows on the page or all rows in the dataset β€” clarify. Most implementations do "select all on page" by default, with a banner like "Select all 45,000 rows" that appears after checking the header.

6. Sticky Header

.table-header {
  position: sticky;
  top: 0;
  z-index: 10;
  background: white;
}

Gotcha: With virtualisation, the header lives outside the scroll container. If the header is inside the virtualised body, it will scroll away. Structure the table as:

<div class="table-container">
  <div class="table-header">...</div>     <!-- Outside scroll -->
  <div class="table-scroll">              <!-- Scroll container -->
    <div class="virtual-body">...</div>   <!-- Virtualised rows -->
  </div>
  <div class="table-footer">...</div>     <!-- Pagination -->
</div>

O β€” Optimisation & Edge Cases

Performance Checklist

TechniqueImpactWhen to Apply
VirtualisationCritical>500 rows
Debounced filteringHighUser-typed search
Memoised selectorsHighExpensive accessor functions
React.memo on rowsMediumComplex cell renderers
Web Worker for sortingMedium>50K rows client-side
Server-side paginationCritical>100K rows
Column lazy renderingLowMany hidden columns

Mobile Responsiveness

On narrow screens, hide low-priority columns:

const visibleColumns = columns.filter(
  col => !col.hiddenOnMobile || window.innerWidth > 768
);

For truly mobile-friendly tables, consider a "card view" fallback β€” render each row as a stacked card with the column label repeated:

Name:    John Doe
Email:   john@example.com
Status:  Active

Accessibility

A data table needs proper ARIA roles to work with screen readers:

<div role="grid" aria-rowcount="10000" aria-colcount="5">
  <div role="row" role="rowgroup">
    <div role="columnheader" aria-sort="ascending">Name</div>
  </div>
  <div role="row">
    <div role="gridcell">John Doe</div>
  </div>
</div>

Keyboard navigation:

  • Arrow Up/Down β€” move focus between rows
  • Arrow Left/Right β€” move focus between cells
  • Enter β€” toggle row selection
  • Space β€” toggle select-all
  • Escape β€” clear selection / close filters

Edge Cases to Mention

  1. Empty state β€” Show a helpful message when filters return zero rows ("No results found. Try adjusting your filters.")
  2. Loading state β€” Skeleton rows while data is being fetched from the server
  3. Error state β€” Retry button with error message if the data fetch fails
  4. Wide tables β€” Horizontal scroll with sticky first column (row labels)
  5. Sortable non-string columns β€” Numbers sort numerically, dates sort chronologically, not alphabetically
  6. Selection persistence β€” Selected rows should persist when sorting or filtering changes (don't lose user's selection because they re-sorted)

Summary: What Makes a Great Data Table Answer

  • Clarify requirements first β€” client-side or server-side? How many rows? Mobile support?
  • Pipeline ordering matters β€” Filter β†’ Sort β†’ Paginate β†’ Virtualise. Getting this wrong is the #1 bug source.
  • Virtualisation is non-negotiable for large datasets. Explain the windowing concept and why transform beats padding.
  • Tri-state sort and select-all β€” These small UX details show production experience.
  • Accessibility is part of the design, not an afterthought β€” ARIA roles and keyboard nav should be in the initial design, not bolted on.
  • Mobile strategy β€” Know your breakpoint behaviour before the interviewer asks.

If you can walk through all of the above in 25–30 minutes, you'll have given a senior-level answer that covers everything an interviewer is looking for.


Want to practise more frontend system design questions? Check out the full Frontend System Design series.