Hey folks, Rahul here ๐
Google Sheets, Airtable, Notion databases โ data grids are the backbone of productivity software. And building one is an absolute beast: you need 2D virtual scrolling (rows AND columns), cell selection state machines, formula evaluation, copy-paste interop with Excel, and real-time collaboration โ all while rendering 100K+ cells at 60fps.
This is the Everest of frontend system design. Let's climb it.
R โ Requirements
Functional Requirements
- Render a grid of rows ร columns with scroll in both axes
- Cell editing: click/double-click to edit, Enter to confirm
- Cell selection: single cell, range (Shift+Click), multi-range (Ctrl+Click)
- Column resizing, reordering, sorting, and filtering
- Row selection and bulk operations
- Copy/paste with clipboard interop (Tab-separated for Excel)
- Basic formulas: =SUM(A1:A10), =IF(B1>5, "yes", "no")
- Cell formatting: number format, date format, conditional colors
Non-Functional Requirements
- Performance: 100K rows ร 50 columns at 60fps
- Memory: Only instantiate visible cells (virtualize both axes)
- Keyboard: Full spreadsheet navigation (Tab, Enter, arrows, Ctrl+C/V)
- Accessibility: ARIA grid role with row/column headers
- Undo/Redo: Full command history stack
A โ Architecture
2D Virtualization
This is the core technical challenge. Regular virtual lists virtualize one axis. Spreadsheets need both:
interface VirtualGrid {
// Viewport
scrollTop: number;
scrollLeft: number;
viewportWidth: number;
viewportHeight: number;
// Dimensions
rowHeights: number[]; // Variable heights per row
columnWidths: number[]; // Variable widths per column
// Computed visible range
visibleRows: { start: number; end: number };
visibleColumns: { start: number; end: number };
// Overscan (render a few extra rows/cols for smooth scrolling)
overscanRows: number; // Default: 3
overscanColumns: number; // Default: 2
}
function getVisibleRange(
scrollOffset: number,
viewportSize: number,
sizes: number[],
overscan: number
): { start: number; end: number } {
let accumulated = 0;
let start = 0;
let end = 0;
// Find first visible item
for (let i = 0; i < sizes.length; i++) {
if (accumulated + sizes[i] > scrollOffset) {
start = Math.max(0, i - overscan);
break;
}
accumulated += sizes[i];
}
// Find last visible item
accumulated = 0;
for (let i = start; i < sizes.length; i++) {
accumulated += sizes[i];
if (accumulated > scrollOffset + viewportSize) {
end = Math.min(sizes.length - 1, i + overscan);
break;
}
}
return { start, end: end || sizes.length - 1 };
}Component Architecture
DataGrid
โโโ GridHeader // Sticky column headers
โ โโโ ColumnHeader[] // Sortable, resizable
โ โโโ ColumnResizeHandle[]
โโโ GridBody // Virtualized content area
โ โโโ RowNumberColumn // Sticky row numbers (1, 2, 3...)
โ โโโ VirtualCells // Only rendered cells
โ โโโ Cell // Individual cell
โ โโโ CellDisplay // Read-only view
โ โโโ CellEditor // Active editing input
โโโ SelectionOverlay // Blue selection rectangle(s)
โโโ ScrollbarX / ScrollbarY // Custom scrollbars
โโโ FormulaBar // Shows active cell formula
โโโ StatusBar // "SUM: 42, AVG: 7, COUNT: 6"D โ Data Model
Sparse Storage
// Don't store 100K ร 50 cells in memory โ most are empty!
// Use sparse storage: only store cells that have values
type CellKey = string; // "A1", "B2", etc. or "r0c0", "r1c1"
interface CellData {
value: any; // Raw value (number, string, boolean)
formula?: string; // "=SUM(A1:A10)"
displayValue?: string; // Computed/formatted display text
format?: CellFormat;
style?: CellStyle;
}
interface CellFormat {
type: 'text' | 'number' | 'date' | 'currency' | 'percentage';
pattern?: string; // "#,##0.00", "MM/dd/yyyy"
locale?: string;
}
interface CellStyle {
bold?: boolean;
italic?: boolean;
textColor?: string;
bgColor?: string;
alignment?: 'left' | 'center' | 'right';
}
// Sparse data store
class SpreadsheetData {
private cells = new Map<CellKey, CellData>();
getCell(row: number, col: number): CellData | null {
return this.cells.get(`r${row}c${col}`) || null;
}
setCell(row: number, col: number, data: CellData): void {
const key = `r${row}c${col}`;
if (data.value === null && !data.formula && !data.style) {
this.cells.delete(key); // Remove empty cells
} else {
this.cells.set(key, data);
}
}
getCellCount(): number {
return this.cells.size; // Only non-empty cells
}
}Selection State Machine
interface SelectionState {
activeCell: CellRef; // Current "focused" cell
ranges: CellRange[]; // Selected ranges (can be multiple with Ctrl)
isEditing: boolean; // Cell editor is active
editValue: string; // Current editor content
selectionMode: 'idle' | 'selecting' | 'extending';
}
interface CellRef {
row: number;
col: number;
}
interface CellRange {
start: CellRef;
end: CellRef;
}
function selectionReducer(state: SelectionState, action: SelectionAction): SelectionState {
switch (action.type) {
case 'CLICK_CELL':
return {
...state,
activeCell: action.cell,
ranges: [{ start: action.cell, end: action.cell }],
isEditing: false,
};
case 'SHIFT_CLICK_CELL':
// Extend current selection
const lastRange = state.ranges[state.ranges.length - 1];
return {
...state,
ranges: [
...state.ranges.slice(0, -1),
{ start: lastRange.start, end: action.cell },
],
};
case 'CTRL_CLICK_CELL':
// Add new selection range
return {
...state,
activeCell: action.cell,
ranges: [...state.ranges, { start: action.cell, end: action.cell }],
};
case 'DOUBLE_CLICK_CELL':
return {
...state,
activeCell: action.cell,
isEditing: true,
editValue: getCellDisplayValue(action.cell),
};
case 'ARROW_KEY':
if (state.isEditing) return state; // Don't navigate while editing
const next = moveCell(state.activeCell, action.direction);
return {
...state,
activeCell: next,
ranges: [{ start: next, end: next }],
};
case 'ENTER':
if (state.isEditing) {
// Confirm edit and move down
commitEdit(state.activeCell, state.editValue);
const below = { row: state.activeCell.row + 1, col: state.activeCell.col };
return { ...state, isEditing: false, activeCell: below, ranges: [{ start: below, end: below }] };
}
// Start editing
return { ...state, isEditing: true, editValue: '' };
case 'TAB':
// Move right (or to next row start)
const nextTab = state.activeCell.col < maxCol
? { row: state.activeCell.row, col: state.activeCell.col + 1 }
: { row: state.activeCell.row + 1, col: 0 };
return { ...state, activeCell: nextTab, ranges: [{ start: nextTab, end: nextTab }], isEditing: false };
case 'ESCAPE':
return { ...state, isEditing: false };
}
}I โ Interface Definition
Cell Rendering
Copy/Paste with Clipboard API
O โ Optimizations
1. Formula Engine (Simplified)
2. Undo/Redo Command Pattern
3. Column Resize with requestAnimationFrame
Production Gotchas Rahul Has Debugged ๐ฅ
- Sticky Headers + Virtual Scroll: Both row numbers and column headers must stay "stuck" while the body scrolls. Use separate
position: stickyelements synced viascrollLeft/scrollTopevent listeners โ don't put them inside the virtual container. - Circular Formula References:
=A1 references B1 which references A1. Detect cycles in the dependency graph before evaluation, or you get infinite recursion. Cap evaluation depth at 100. - Large Paste Operations: Pasting 10,000 cells triggers 10,000 state updates. Batch all paste operations into a single command, and use
requestIdleCallbackto render incrementally. - IME Input in Cells: CJK users type in composition mode. Don't commit the cell value on every keystroke โ wait for
compositionend. - Number vs. String Detection: When a user types "007", is it the number 7 or the string "007"? Excel auto-converts to number. Google Sheets preserves as string if it starts with 0. Pick a strategy and document it.
That wraps up our frontend system design series! Each article follows the RADIO framework, giving you the structured approach FAANG interviewers expect. Practice articulating these trade-offs out loud โ the interview is as much about communication as it is about technical depth. Good luck! ๐