DevPrep
  • Interview Prep
  • Projects
  • Resources
  • Pricing
  • About Us
Submit Question
DevPrep
  • Pricing
  • About Us
Submit Question
  1. Home
  2. Articles
  3. Frontend Engineering
  4. System Design #15: Design a Spreadsheet / Data Grid
XLinkedInReddit
MediumFrontend Engineering

System Design #15: Design a Spreadsheet / Data Grid

D
DevPrep Team
8 min readยท0
Table of Contents
  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • 2D Virtualization
  • Component Architecture
  • D โ€” Data Model
  • Sparse Storage
  • Selection State Machine
  • 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 ๐Ÿ”ฅ

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

const Cell = memo(function Cell({
  row, col, data, isActive, isSelected, isEditing, onEdit
}: CellProps) {
  const cellRef = useRef<HTMLDivElement>(null);
  
  if (isEditing) {
    return (
      <input
        autoFocus
        className="absolute inset-0 border-2 border-primary z-10 px-1 outline-none"
        defaultValue={data?.formula || data?.value || ''}
        onBlur={(e) => onEdit(e.target.value)}
        onKeyDown={handleEditKeyDown}
      />
    );
  }
  
  return (
    <div
      ref={cellRef}
      role="gridcell"
      aria-colindex={col + 1}
      aria-selected={isSelected}
      tabIndex={isActive ? 0 : -1}
      className={cn(
        "px-1 py-0.5 border-r border-b border-border text-xs truncate",
        isActive && "ring-2 ring-primary ring-inset",
        isSelected && !isActive && "bg-primary/10",
        data?.style?.bold && "font-bold",
      )}
      style={{
        textAlign: data?.style?.alignment || 'left',
        color: data?.style?.textColor,
        backgroundColor: data?.style?.bgColor,
      }}
    >
      {data?.displayValue ?? data?.value ?? ''}
    </div>
  );
});

Copy/Paste with Clipboard API

async function handleCopy(selection: CellRange[], data: SpreadsheetData) {
  const { start, end } = normalizeRange(selection[0]);
  
  // Build tab-separated text (Excel-compatible)
  const rows: string[] = [];
  for (let r = start.row; r <= end.row; r++) {
    const cols: string[] = [];
    for (let c = start.col; c <= end.col; c++) {
      const cell = data.getCell(r, c);
      cols.push(cell?.value?.toString() || '');
    }
    rows.push(cols.join('\t'));
  }
  
  const text = rows.join('\n');
  
  // Also write HTML for rich paste
  const html = buildHtmlTable(selection, data);
  
  await navigator.clipboard.write([
    new ClipboardItem({
      'text/plain': new Blob([text], { type: 'text/plain' }),
      'text/html': new Blob([html], { type: 'text/html' }),
    }),
  ]);
}

async function handlePaste(activeCell: CellRef, data: SpreadsheetData) {
  const clipText = await navigator.clipboard.readText();
  const rows = clipText.split('\n').map(row => row.split('\t'));
  
  const commands: EditCommand[] = [];
  rows.forEach((cols, ri) => {
    cols.forEach((value, ci) => {
      commands.push({
        type: 'SET_CELL',
        row: activeCell.row + ri,
        col: activeCell.col + ci,
        value: parseValue(value),
      });
    });
  });
  
  executeCommands(commands); // Batch for undo
}

O โ€” Optimizations

1. Formula Engine (Simplified)

class FormulaEngine {
  private depGraph = new Map<string, Set<string>>(); // cell โ†’ cells that depend on it
  
  evaluate(formula: string, data: SpreadsheetData): any {
    // Parse: "=SUM(A1:A10)" โ†’ { fn: "SUM", args: [Range("A1", "A10")] }
    const parsed = parseFormula(formula);
    
    switch (parsed.fn) {
      case 'SUM':
        return this.resolveRange(parsed.args[0], data)
          .reduce((sum, v) => sum + (Number(v) || 0), 0);
      case 'AVERAGE':
        const values = this.resolveRange(parsed.args[0], data).filter(v => v !== null);
        return values.reduce((s, v) => s + Number(v), 0) / values.length;
      case 'IF':
        const condition = this.evaluateExpression(parsed.args[0], data);
        return condition ? parsed.args[1] : parsed.args[2];
      case 'COUNT':
        return this.resolveRange(parsed.args[0], data).filter(v => v !== null).length;
    }
  }
  
  // When A1 changes, recalculate all cells that reference A1
  onCellChange(cellKey: string, data: SpreadsheetData) {
    const dependents = this.depGraph.get(cellKey) || new Set();
    for (const dep of dependents) {
      const cell = data.getCell(...parseCellKey(dep));
      if (cell?.formula) {
        cell.displayValue = String(this.evaluate(cell.formula, data));
        // Recursive: this cell changing may trigger other recalcs
        this.onCellChange(dep, data);
      }
    }
  }
}

2. Undo/Redo Command Pattern

interface EditCommand {
  execute(data: SpreadsheetData): void;
  undo(data: SpreadsheetData): void;
  description: string;
}

class UndoManager {
  private undoStack: EditCommand[] = [];
  private redoStack: EditCommand[] = [];
  
  execute(command: EditCommand, data: SpreadsheetData) {
    command.execute(data);
    this.undoStack.push(command);
    this.redoStack = []; // Clear redo on new action
  }
  
  undo(data: SpreadsheetData) {
    const command = this.undoStack.pop();
    if (!command) return;
    command.undo(data);
    this.redoStack.push(command);
  }
  
  redo(data: SpreadsheetData) {
    const command = this.redoStack.pop();
    if (!command) return;
    command.execute(data);
    this.undoStack.push(command);
  }
}

// Example command
class SetCellCommand implements EditCommand {
  private prevData: CellData | null;
  
  constructor(
    private row: number,
    private col: number,
    private newData: CellData,
  ) {}
  
  execute(data: SpreadsheetData) {
    this.prevData = data.getCell(this.row, this.col);
    data.setCell(this.row, this.col, this.newData);
  }
  
  undo(data: SpreadsheetData) {
    if (this.prevData) {
      data.setCell(this.row, this.col, this.prevData);
    } else {
      data.setCell(this.row, this.col, { value: null });
    }
  }
  
  get description() { return `Edit cell R${this.row}C${this.col}`; }
}

3. Column Resize with requestAnimationFrame

function useColumnResize(
  colIndex: number,
  onResize: (col: number, width: number) => void
) {
  const handleMouseDown = (e: React.MouseEvent) => {
    e.preventDefault();
    const startX = e.clientX;
    const startWidth = columnWidths[colIndex];
    
    const handleMouseMove = (e: MouseEvent) => {
      requestAnimationFrame(() => {
        const delta = e.clientX - startX;
        const newWidth = Math.max(40, startWidth + delta);
        onResize(colIndex, newWidth);
      });
    };
    
    const handleMouseUp = () => {
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };
    
    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);
  };
  
  return handleMouseDown;
}

Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

  1. Sticky Headers + Virtual Scroll: Both row numbers and column headers must stay "stuck" while the body scrolls. Use separate position: sticky elements synced via scrollLeft/scrollTop event listeners โ€” don't put them inside the virtual container.
  2. 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.
  3. Large Paste Operations: Pasting 10,000 cells triggers 10,000 state updates. Batch all paste operations into a single command, and use requestIdleCallback to render incrementally.
  4. IME Input in Cells: CJK users type in composition mode. Don't commit the cell value on every keystroke โ€” wait for compositionend.
  5. 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! ๐Ÿš€

Related Articles

MediumFrontend Engineering

System Design #12: Design a Multi-Step Form Wizard

7 min read
MediumFrontend Engineering

Mastering Senior-Level JavaScript Interview Concepts

2 min read
MediumFrontend Engineering

System Design #9: Design a Collaborative Text Editor

9 min read

Comments (0)

Sign in to leave a comment.

No comments yet. Be the first to comment.

Table of Contents

  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • 2D Virtualization
  • Component Architecture
  • D โ€” Data Model
  • Sparse Storage
  • Selection State Machine
  • 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 ๐Ÿ”ฅ

Series

View all Frontend Engineering articles โ†’

Practice

  • JavaScript
  • DSA
  • Machine Coding
  • System Design

Resources

  • Learning Tracks
  • Articles
  • Roadmaps
  • Compare Concepts
  • Glossary
  • Developer Tools
  • All Questions

Company

  • About
  • Pricing

Legal

  • Privacy Policy
  • Terms of Service
DevPrep

ยฉ 2026 DevPrep. All rights reserved.