ESC

Type to search the knowledge base.

Lite Spreadsheet Grid

Machine-coding brief for a mini spreadsheet — cell model, selection, edit mode, keyboard nav, and formula hook points.

advanced5 min read
  • machine-coding
  • interview
  • react
  • grid

Problem statement

Build a lite spreadsheet grid: rows × columns of cells, navigate with arrows, edit on Enter/type, commit on Enter/blur, cancel on Escape. Interviewers score selection vs edit modes, focus management, and data modeling. Formulas are optional extension — parse =A1+B1 only if time allows.

Requirements

Must have

  • Fixed grid size (e.g. 10×10) or configurable rows / cols
  • Cell values stored by address (A1) or [r][c]
  • Click selects cell; double-click or Enter starts edit
  • Arrow keys move selection when not editing
  • Type printable character starts edit replacing value
  • Tab moves to next cell (commit)

Should have

  • Column headers A,B,C… and row numbers
  • Display value vs raw value (for formulas later)
  • Copy single cell (Ctrl/Cmd+C) — optional paste

Nice to have

  • Fill formula evaluation graph
  • Multi-cell selection
  • Virtualization for 1000×50
  • Column resize

Planning (5 minutes out loud)

  1. Modes — navigating vs editing (state machine)
  2. Addressing — colToLetter(c) + (r+1)
  3. One input — render editor only in active cell
  4. MVP — select + edit + arrows; then tab + headers
  5. Formulas last — don’t derail the grid UX

Architecture

Spreadsheet
├── HeaderRow
├── Grid
│   └── Cell (display | editor)
└── useSheetState

Data model

type CellAddr = string; // "A1"

type CellData = {
  raw: string; // user input
};

type SheetState = {
  cells: Record<CellAddr, CellData>;
  active: { r: number; c: number };
  mode: "navigate" | "edit";
  draft: string;
};

function colLetter(c: number) {
  let n = c;
  let s = "";
  do {
    s = String.fromCharCode(65 + (n % 26)) + s;
    n = Math.floor(n / 26) - 1;
  } while (n >= 0);
  return s;
}

function addr(r: number, c: number): CellAddr {
  return `${colLetter(c)}${r + 1}`;
}

Implementation sketch

function Spreadsheet({ rows = 10, cols = 6 }: { rows?: number; cols?: number }) {
  const [cells, setCells] = useState<Record<CellAddr, CellData>>({});
  const [active, setActive] = useState({ r: 0, c: 0 });
  const [mode, setMode] = useState<"navigate" | "edit">("navigate");
  const [draft, setDraft] = useState("");
  const inputRef = useRef<HTMLInputElement>(null);

  const activeAddr = addr(active.r, active.c);

  function commit() {
    setCells((prev) => ({ ...prev, [activeAddr]: { raw: draft } }));
    setMode("navigate");
  }

  function cancel() {
    setMode("navigate");
    setDraft(cells[activeAddr]?.raw ?? "");
  }

  function beginEdit(seed?: string) {
    setDraft(seed ?? cells[activeAddr]?.raw ?? "");
    setMode("edit");
  }

  useEffect(() => {
    if (mode === "edit") inputRef.current?.focus();
  }, [mode, activeAddr]);

  function move(dr: number, dc: number) {
    setActive((a) => ({
      r: Math.min(rows - 1, Math.max(0, a.r + dr)),
      c: Math.min(cols - 1, Math.max(0, a.c + dc)),
    }));
  }

  function onKeyDown(e: React.KeyboardEvent) {
    if (mode === "edit") {
      if (e.key === "Enter") {
        e.preventDefault();
        commit();
        move(1, 0);
      } else if (e.key === "Escape") {
        e.preventDefault();
        cancel();
      } else if (e.key === "Tab") {
        e.preventDefault();
        commit();
        move(0, e.shiftKey ? -1 : 1);
      }
      return;
    }
    // navigate
    if (e.key === "ArrowUp") {
      e.preventDefault();
      move(-1, 0);
    } else if (e.key === "ArrowDown") {
      e.preventDefault();
      move(1, 0);
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      move(0, -1);
    } else if (e.key === "ArrowRight") {
      e.preventDefault();
      move(0, 1);
    } else if (e.key === "Enter") {
      e.preventDefault();
      beginEdit();
    } else if (e.key === "Backspace" || e.key === "Delete") {
      setCells((prev) => ({ ...prev, [activeAddr]: { raw: "" } }));
    } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
      beginEdit(e.key);
    }
  }

  return (
    <div
      className="sheet"
      role="grid"
      aria-label="Spreadsheet"
      tabIndex={mode === "navigate" ? 0 : -1}
      onKeyDown={onKeyDown}
    >
      {/* map rows/cols; active cell shows <input ref={inputRef} value={draft} ... /> */}
    </div>
  );
}

Display cell

function CellView({
  r,
  c,
  value,
  isActive,
  mode,
  draft,
  inputRef,
  onChangeDraft,
  onCommit,
}: {
  r: number;
  c: number;
  value: string;
  isActive: boolean;
  mode: "navigate" | "edit";
  draft: string;
  inputRef: RefObject<HTMLInputElement | null>;
  onChangeDraft: (v: string) => void;
  onCommit: () => void;
}) {
  const editing = isActive && mode === "edit";
  return (
    <div
      role="gridcell"
      aria-selected={isActive}
      className={isActive ? "cell active" : "cell"}
    >
      {editing ? (
        <input
          ref={inputRef}
          value={draft}
          aria-label={`Edit ${addr(r, c)}`}
          onChange={(e) => onChangeDraft(e.target.value)}
          onBlur={onCommit}
        />
      ) : (
        <span>{value}</span>
      )}
    </div>
  );
}

Tiny formula evaluator (extension)

function displayValue(raw: string, get: (a: CellAddr) => string): string {
  if (!raw.startsWith("=")) return raw;
  // extremely simplified: =A1+B1 only
  const m = /^=([A-Z]+\d+)\+([A-Z]+\d+)$/.exec(raw);
  if (!m) return "#ERR";
  const a = Number(get(m[1]) || 0);
  const b = Number(get(m[2]) || 0);
  if (Number.isNaN(a) || Number.isNaN(b)) return "#ERR";
  return String(a + b);
}

Mention dependency cycles and topo-sort only verbally unless asked to implement.

Accessibility essentials

  • role="grid" / row / gridcell
  • aria-selected on active cell
  • Editor input labeled with address
  • Grid container focusable in navigate mode
  • Don’t trap focus without Escape path from edit mode

Performance notes

  • Sparse Record<addr, cell> — don’t preallocate 1M empty objects
  • Memo cell components; only active editor mounts input
  • Virtualize when rows * cols is large
  • Avoid full-grid re-render on draft typing — isolate editor state

Footguns

  1. Arrows move caret and selection — in edit mode don’t move selection
  2. Blur commit racing with clicking another cell (order of events)
  3. IME composition — don’t hijack keys during isComposing
  4. Focus loss when rerendering input each keystroke without stable ref
  5. Formula injection if ever eval — never use eval

Interview out-loud answer

Spreadsheet UX is a state machine: navigate vs edit. Cells live in a sparse map keyed by A1 address. Only the active cell mounts an input. Arrows move in navigate mode; Enter/type enters edit; Enter/Tab commits; Escape cancels. Formulas are a display-layer parse over raw strings with explicit dependency handling later. Virtualization is the scaling story.

Further reading