Implement Tic Tac Toe in React
Build a 3x3 grid with turn tracking, win/draw detection, and reset. Surface state shape, win-line generation, immutability, and how to extend to NxN as the senior signal.
Tic Tac Toe is the canonical "implement this from scratch in 30 minutes" frontend interview prompt. It looks trivial but is actually a great signal of clean React state design, immutability, win-condition modeling, and how you handle extension questions ("now make it 4x4", "add undo/redo", "support two-player online"). The wrong way is to dive into JSX immediately; the right way is to pin down state shape, derived state, and win logic in your head first, then code.
Clarifying questions to ask first (signals product instinct):
- Single device hot-seat, or networked multiplayer?
- 3×3 only, or generalize to N×N with K-in-a-row?
- Should there be a status line ("X's turn", "X wins", "Draw")?
- Reset button? Move history / undo?
- Accessibility (keyboard nav, screen reader)?
For a 30-minute coding round, scope to: 3×3, hot-seat, status line, reset. Mention the other axes when you take the assignment so the interviewer knows you saw them.
State design. Three pieces of state. Don't store anything derivable.
board: ("X" | "O" | null)[]— length 9 (or[N][N]), immutable updates only.xIsNext: boolean(orturn: "X" | "O") — whose move.- Optional:
history: typeof board[]for undo / time-travel.
Derived state (compute on each render, don't store):
winner—calculateWinner(board)returns"X","O", ornull.isDraw—board.every(Boolean) && !winner.status— string built from winner / isDraw / xIsNext.
Putting winner in state is a common mistake: now you have two sources of truth and have to remember to update both. Always derive what can be derived.
Win-line generation. For 3×3, the 8 winning lines are hardcoded:
const LINES = [
[0,1,2],[3,4,5],[6,7,8], // rows
[0,3,6],[1,4,7],[2,5,8], // cols
[0,4,8],[2,4,6], // diagonals
];
function calculateWinner(b: Board): Player | null {
for (const [a,b2,c] of LINES) {
if (b[a] && b[a] === b[b2] && b[a] === b[c]) return b[a];
}
return null;
}For an N×N variant with K-in-a-row, generate lines programmatically — every (row, col) is the start of up to 4 lines (right, down, diag, anti-diag) of length K. The senior follow-up usually is "now make it 5-in-a-row on a 15×15 board" — having the generator pattern in your back pocket is a strong signal.
Component structure.
function Game() {
const [board, setBoard] = useState<Board>(Array(9).fill(null));
const [xIsNext, setXIsNext] = useState(true);
const winner = calculateWinner(board);
const isDraw = !winner && board.every(Boolean);
const status = winner ? `Winner: ${winner}` : isDraw ? "Draw" : `Next: ${xIsNext ? "X" : "O"}`;
function handleClick(i: number) {
if (board[i] || winner) return; // guard
const next = board.slice();
next[i] = xIsNext ? "X" : "O";
setBoard(next);
setXIsNext(v => !v);
}
function reset() {
setBoard(Array(9).fill(null));
setXIsNext(true);
}
return (
<div role="grid" aria-label="Tic Tac Toe">
<div aria-live="polite">{status}</div>
<div className="grid grid-cols-3 gap-1">
{board.map((cell, i) => (
<button
key={i}
role="gridcell"
aria-label={`cell ${i + 1} ${cell ?? "empty"}`}
onClick={() => handleClick(i)}
disabled={!!cell || !!winner}
>{cell}</button>
))}
</div>
<button onClick={reset}>Reset</button>
</div>
);
}Things to surface as you code (this is the rating signal):
- Immutability — never
board[i] = ...; alwaysboard.slice()or[...board]then mutate the copy. Without this, React doesn't see the change. - Guards in the click handler — ignore clicks on filled cells and after a winner is declared.
- Accessibility —
role="grid"/role="gridcell",aria-livestatus,aria-labelper cell so screen readers can announce "cell 5, empty" and updates. - Keyboard support — arrow keys to navigate, Enter / Space to play. Optional but a great senior touch.
- Lift state only as far as needed — board state in
<Game>, each<Square>is a controlled child. - Don't store derived values. Compute
winnerandstatuson render. - Strict types —
type Player = "X" | "O",type Cell = Player | null,type Board = Cell[].
Likely extension questions and how to handle them:
- Undo / time travel. Store
history: Board[]instead of justboard; trackcurrentMove. The board ishistory[currentMove]. xIsNext can be derived fromcurrentMove % 2 === 0. - N×N board, K in a row. Move
LINESto a generator function;calculateWinneriterates lines. - AI opponent. Minimax with alpha-beta pruning for 3×3 (game tree is tiny; precompute the optimal move). For larger boards, depth-limited search or MCTS.
- Multiplayer. Lift state to a server (WebSocket or Firestore); each move is an action; clients render from server state. Discuss conflict resolution if both players click simultaneously (server is the arbiter; use turn tokens).
- Highlight winning line. Return the line indices from
calculateWinner, not just the winner. Style those squares differently. - Animations / haptics. Reach for Framer Motion or CSS transitions on cell fills.
Common mistakes interviewers note:
- Mutating the board array (
board[i] = "X"; setBoard(board)) — React skips the render because the reference is the same. - Storing
winnerin state and forgetting to update it. - No guard for clicking after game over → state desync.
- Building 9 separate
useStates for each cell. Don't. - Ignoring accessibility entirely.
- Hardcoding "3×3" everywhere — fine for the base, but be ready to refactor.
Time budget for a 30-minute round: 3 min clarify + state design, 12 min core implementation, 5 min styling and accessibility, 5 min extension discussion / bug-finding, 5 min buffer. Code less, narrate more — the interviewer is grading the thinking, not the keystrokes.
Code
Follow-up questions
- •Add undo / redo with full move history.
- •Generalize to N×N with K-in-a-row.
- •Add a minimax AI opponent.
- •Make it two-player online with WebSocket.
- •Highlight the winning line.
Common mistakes
- •Mutating the board array directly so React doesn't re-render.
- •Storing winner / status in state instead of deriving on render.
- •Allowing clicks on filled cells or after game over.
- •Using 9 separate useState calls instead of one array.
- •Ignoring accessibility (no aria-live, no labels).
Performance considerations
- •Tic Tac Toe is trivial; no perf concerns. For NxN with very large N, memoize calculateWinner and skip recomputation when only one cell changed.
Edge cases
- •Both players' turn tracking after undo — derive xIsNext from move count, not stored flag.
- •Reset mid-game must clear both board and turn state atomically.
- •Simultaneous clicks in multiplayer — server arbitrates with a turn token.
Real-world examples
- •The React docs use Tic Tac Toe as the introductory tutorial — interviewers know candidates have seen it. The differentiator is whether you treat it as a 'memorized exercise' or a 'design problem with extensions.'