Dynamic Tic Tac Toe
by Pratik Rai
Read the full write-upBuild tic tac toe on a board whose size the player chooses — 3x3, 4x4 or 5x5. A line of n marks in a row wins.
winningLines(n) is given: it returns every winning line as an array of cell indices.
Requirements
X moves first, and turns alternate.
Clicking a filled cell does nothing, and no move is possible once the game is over.
A full line of the same mark — row, column or either diagonal — wins, on any board size.
The status line shows whose turn it is, who won, or that it is a draw.
The winning cells are highlighted with the
.cell-winningclass.Changing the board size or pressing Reset starts a fresh game with X to move.
Notes
A draw is a full board with no winner.
Cell indices are flat: row
r, columncis indexr * size + c.No AI opponent — two players share the board.
Hints
Dynamic Tic Tac Toe (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState } from 'react';
2
3// Every line that wins, for an n x n board: rows, columns, both diagonals.
4// Returns arrays of cell indices.
5function winningLines(n) {
6 const lines = [];
7 for (let r = 0; r < n; r++) {
8 lines.push(Array.from({ length: n }, (_, c) => r * n + c));
9 }
10 for (let c = 0; c < n; c++) {
11 lines.push(Array.from({ length: n }, (_, r) => r * n + c));
12 }
13 lines.push(Array.from({ length: n }, (_, i) => i * n + i));
14 lines.push(Array.from({ length: n }, (_, i) => i * n + (n - 1 - i)));
15 return lines;
16}
17
18function findWinner(board, size) {
19 for (const line of winningLines(size)) {
20 const first = board[line[0]];
21 if (first && line.every((i) => board[i] === first)) {
22 return { mark: first, line };
23 }
24 }
25 return null;
26}
27
28export default function App() {
29 const [size, setSize] = useState(3);
30 const [board, setBoard] = useState(() => Array(9).fill(null));
31 const [xIsNext, setXIsNext] = useState(true);
32
33 // Derived, not stored. The winner is a fact about the board — keeping it in
34 // state means keeping it in sync, and every stale-winner bug starts there.
35 const result = findWinner(board, size);
36 const winner = result?.mark ?? null;
37 const winningCells = result?.line ?? [];
38 const isDraw = !winner && board.every((cell) => cell !== null);
39
40 const handleClick = (index) => {
41 if (board[index] || winner) return;
42 // Copy, then write. Editing `board` in place would hand React the same
43 // array reference and nothing would re-render.
44 const next = board.slice();
45 next[index] = xIsNext ? 'X' : 'O';
46 setBoard(next);
47 setXIsNext(!xIsNext);
48 };
49
50 const reset = (nextSize) => {
51 setSize(nextSize);
52 setBoard(Array(nextSize * nextSize).fill(null));
53 setXIsNext(true);
54 };
55
56 const status = winner
57 ? winner + ' wins'
58 : isDraw
59 ? 'Draw'
60 : 'Turn: ' + (xIsNext ? 'X' : 'O');
61
62 return (
63 <div>
64 <h1>Tic tac toe</h1>
65
66 <div className="controls">
67 <label htmlFor="size">Board</label>
68 <select
69 id="size"
70 value={size}
71 onChange={(event) => reset(Number(event.target.value))}
72 >
73 {[3, 4, 5].map((n) => (
74 <option key={n} value={n}>{n} x {n}</option>
75 ))}
76 </select>
77 </div>
78
79 <p className="status" aria-live="polite">{status}</p>
80
81 <div
82 className="board"
83 style={{ gridTemplateColumns: 'repeat(' + size + ', 64px)' }}
84 >
85 {board.map((value, index) => (
86 <button
87 key={index}
88 className={
89 'cell' + (winningCells.includes(index) ? ' cell-winning' : '')
90 }
91 onClick={() => handleClick(index)}
92 disabled={Boolean(value) || Boolean(winner)}
93 aria-label={'Cell ' + (index + 1) + (value ? ', ' + value : ', empty')}
94 >
95 {value}
96 </button>
97 ))}
98 </div>
99
100 <button className="reset" onClick={() => reset(size)}>
101 Reset
102 </button>
103 </div>
104 );
105}How it works
Two pieces of state — the board and whose turn it is — and everything else is derived. That is the answer the interviewer is listening for. Storing winner in state means every move has to remember to update it, and the day someone adds an undo button it silently goes stale. Recomputing it on each render costs a scan of at most 2n + 2 lines, which for a 5x5 board is twelve short loops.
Generalising to n x n is why winningLines is written as index arrays rather than the hardcoded triples you usually see. Once a line is just a list of indices, the win check is the same three lines whatever the board size, and the highlight falls out of line.includes(index).
Disabling filled cells does the same job as the early return in handleClick, but it also tells the keyboard and screen readers that those cells are not choices any more — the guard alone leaves them looking available.