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:
-
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. -
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.
-
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 β RenderSorting 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:
- Unchecked β no rows on the current page are selected
- Checked β all rows on the current page are selected
- 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
| Technique | Impact | When to Apply |
|---|---|---|
| Virtualisation | Critical | >500 rows |
| Debounced filtering | High | User-typed search |
| Memoised selectors | High | Expensive accessor functions |
React.memo on rows | Medium | Complex cell renderers |
| Web Worker for sorting | Medium | >50K rows client-side |
| Server-side pagination | Critical | >100K rows |
| Column lazy rendering | Low | Many 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: ActiveAccessibility
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 rowsArrow Left/Rightβ move focus between cellsEnterβ toggle row selectionSpaceβ toggle select-allEscapeβ clear selection / close filters
Edge Cases to Mention
- Empty state β Show a helpful message when filters return zero rows ("No results found. Try adjusting your filters.")
- Loading state β Skeleton rows while data is being fetched from the server
- Error state β Retry button with error message if the data fetch fails
- Wide tables β Horizontal scroll with sticky first column (row labels)
- Sortable non-string columns β Numbers sort numerically, dates sort chronologically, not alphabetically
- 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
transformbeatspadding. - 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.