Build a Grid Toggle UI component (immutable state, 2D structures, predictable re-renders)
Build a grid of toggleable cells. The graded concepts are in the title: immutable state updates (copy before write, functional updater), modeling 2D structures (1D array with index math, or 2D array copied carefully), and predictable re-renders (stable keys, memoized cells, lifted state). Show you can update one cell without mutating or over-rendering.
This prompt names exactly what it's grading: immutable state, 2D data structures, and predictable re-renders. Build a grid of cells that toggle on click, and demonstrate each.
1. Modeling the 2D structure
Two choices:
// 1D array + index math — easiest to update immutably
const idx = (r: number, c: number) => r * cols + c;
const [cells, setCells] = useState<boolean[]>(() => Array(rows * cols).fill(false));
// 2D array — intuitive shape, but immutable updates are more verbose
const [grid, setGrid] = useState<boolean[][]>(
() => Array.from({ length: rows }, () => Array(cols).fill(false))
);2. Immutable updates
Never mutate state. Copy the path you change:
// 1D version
const toggle = (i: number) =>
setCells((prev) => prev.map((v, j) => (j === i ? !v : v)));
// 2D version — copy the outer array AND the affected row
const toggle2D = (r: number, c: number) =>
setGrid((prev) =>
prev.map((row, ri) =>
ri === r ? row.map((v, ci) => (ci === c ? !v : v)) : row
)
);Key points: functional updater (prev => ...) so it's correct under batching/concurrency; copy only what changed (the 2D version keeps unchanged row references stable — which helps memoization).
3. Predictable re-renders
Naively, toggling one cell re-renders every cell. Fix it:
const Cell = React.memo(function Cell({ on, onToggle }: CellProps) {
return <button className={on ? "cell on" : "cell"} aria-pressed={on} onClick={onToggle} />;
});For React.memo to actually work, the onToggle prop must be referentially stable — so pass the index and use a stable handler, or useCallback:
{cells.map((on, i) => (
<Cell key={i} on={on} onToggle={useCallback(() => toggle(i), [i])} />
))}(Cleaner: a single stable handler that reads data-index, avoiding per-cell closures entirely.)
Now only the toggled cell — whose on prop actually changed — re-renders. With the 2D version, keeping unchanged row references stable lets you memoize at the row level too.
4. Stable keys
Use a stable key. Index keys are acceptable here because cells never reorder or get inserted — only their value changes. If cells could be added/removed/reordered, you'd need an id.
What the interviewer is verifying
| Graded concept | What they want to see |
|---|---|
| Immutable state | .map/spread copies, functional updater, no mutation |
| 2D structures | A sensible representation + correct nested immutable update |
| Predictable re-renders | React.memo + stable handler refs, so one toggle ≠ N renders |
Senior framing
The senior answer connects the three: immutability enables predictable re-renders — because unchanged cells keep the same prop references, React.memo can bail out. Mutation would break that. Demonstrating the memo + stable-callback combo (and explaining why it's needed, not just sprinkling useCallback) is the differentiator.
Follow-up questions
- •Why does React.memo need a referentially stable onToggle prop?
- •How does immutability enable render bailouts?
- •When are index keys acceptable and when not?
Common mistakes
- •Mutating a row in place in the 2D array (the outer .map still 'works' but breaks memo).
- •React.memo on cells but passing a fresh inline arrow each render — memo never bails.
- •Not using the functional updater.
- •Reaching for useCallback everywhere without understanding why.
Edge cases
- •2D immutable updates must copy both the outer array and the changed row.
- •A single shared handler reading data-index avoids N useCallback closures.
- •Index keys break if cells can be reordered or inserted.
Real-world examples
- •Seat pickers, pixel editors, lights-out puzzles, spreadsheet-like grids.