Lite Spreadsheet Grid
Machine-coding brief for a mini spreadsheet — cell model, selection, edit mode, keyboard nav, and formula hook points.
- 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)
- Modes —
navigatingvsediting(state machine) - Addressing —
colToLetter(c) + (r+1) - One input — render editor only in active cell
- MVP — select + edit + arrows; then tab + headers
- 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/gridcellaria-selectedon 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
- Arrows move caret and selection — in edit mode don’t move selection
- Blur commit racing with clicking another cell (order of events)
- IME composition — don’t hijack keys during
isComposing - Focus loss when rerendering input each keystroke without stable ref
- Formula injection if ever
eval— never useeval
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.
Related on this site
- Sortable Data Table
- Virtualized Data Table
- Code Editor Line Numbers
- Machine Coding Interview Framework