Back to Machine Coding
Machine Coding
medium
mid

How would you 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.

9 min read·~30 min to think through

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 (or turn: "X" | "O") — whose move.
  • Optional: history: typeof board[] for undo / time-travel.

Derived state (compute on each render, don't store):

  • winnercalculateWinner(board) returns "X", "O", or null.
  • isDrawboard.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:

ts
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.

tsx
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] = ...; always board.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.
  • Accessibilityrole="grid" / role="gridcell", aria-live status, aria-label per 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 winner and status on render.
  • Strict typestype 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 just board; track currentMove. The board is history[currentMove]. xIsNext can be derived from currentMove % 2 === 0.
  • N×N board, K in a row. Move LINES to a generator function; calculateWinner iterates 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 winner in 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

tsx
type Player = "X" | "O";
type Cell = Player | null;
type Board = Cell[];

const LINES = [
  [0,1,2],[3,4,5],[6,7,8],
  [0,3,6],[1,4,7],[2,5,8],
  [0,4,8],[2,4,6],
];

function calculateWinner(b: Board): Player | null {
  for (const [a,c,d] of LINES) {
    if (b[a] && b[a] === b[c] && b[a] === b[d]) return b[a] as Player;
  }
  return null;
}

export 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 play(i: number) {
    if (board[i] || winner) return;
    const next = board.slice();
    next[i] = xIsNext ? "X" : "O";
    setBoard(next);
    setXIsNext(v => !v);
  }

  return (
    <div>
      <div aria-live="polite">{status}</div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 64px)", gap: 4 }}>
        {board.map((c, i) => (
          <button key={i} onClick={() => play(i)} disabled={!!c || !!winner} style={{ height: 64 }}>
            {c}
          </button>
        ))}
      </div>
      <button onClick={() => { setBoard(Array(9).fill(null)); setXIsNext(true); }}>Reset</button>
    </div>
  );
}
Full minimal implementation

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.'

Senior engineer discussion

Senior signal: deriving state, immutability, accessibility, and being ready to refactor to N×N or add networked multiplayer with clear protocol design.