Skip to content
aviral gupta

// B1.5 · ~40 min · Beginner

Build: tic-tac-toe with history

In this build you finish the tic-tac-toe game: Game keeps every board in a history, lists the moves, and lets players jump back in time.

Lesson 5 of 5 in B1 Quick start

End of the module

You will be able to

  • Lift the state into Game as a history of boards, and control Board with the props xIsNext, squares and onPlay
  • Render the move list with map and give each item a key
  • Jump to a past move, drop the later moves when playing on, and compute xIsNext from currentMove
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B1.4: lifted state and turns. You click the third square, then the first. What do the squares show? (_ marks an empty square.)

    function Square({ value, onSquareClick }) {
      return <button onClick={onSquareClick}>{value}</button>;
    }
    
    function Board() {
      const [xIsNext, setXIsNext] = useState(true);
      const [squares, setSquares] = useState([null, null, null]);
    
      function handleClick(i) {
        if (squares[i]) {
          return;
        }
        const nextSquares = squares.slice();
        nextSquares[i] = xIsNext ? 'X' : 'O';
        setSquares(nextSquares);
        setXIsNext(!xIsNext);
      }
    
      return (
        <div>
          <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
          <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
          <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
        </div>
      );
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. Game will keep every board in an array called history. What does this print?

    const history = [[null, null, null]];
    const nextSquares = ['X', null, null];
    const nextHistory = [...history, nextSquares];
    
    console.log(history.length, nextHistory.length, nextHistory[0][0]);
  3. Practice · Activity 3 of 7

    Fill in the index so that Game shows the board of the move the player picked, not always the last one.

    const currentSquares = history[____];
    const currentSquares = history[];
  4. Practice · Activity 4 of 7

    Match each piece of the finished game to what it does.

  5. Practice · Activity 5 of 7

    history holds three boards: the empty start and two moves. What do the buttons say?

    function Moves() {
      const history = [
        [null, null],
        ['X', null],
        ['X', 'O']
      ];
    
      const moves = history.map((squares, move) => {
        let description;
        if (move > 0) {
          description = 'Go to move #' + move;
        } else {
          description = 'Go to game start';
        }
        return (
          <li key={move}>
            <button>{description}</button>
          </li>
        );
      });
    
      return <ol>{moves}</ol>;
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The players went back to move 1 and now play a new move. What does this print?

    const history = ['start', 'move 1', 'move 2', 'move 3'];
    const currentMove = 1;
    const nextSquares = 'new move';
    
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    console.log(nextHistory.join(' | '));
  7. Apply · Activity 7 of 7

    Mini-task, the first extra challenge from the end of react.dev's tutorial: for the current move only, show "You are at move #…" as plain text instead of a button. Start from the worked example. In the map callback, compare move with currentMove and return a different <li>, with the same key, for the current move. Run it, play a few moves and jump back: the text follows the move you are at.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The finished game with time travel

This is the tutorial's final code. Game keeps history and currentMove, computes xIsNext and currentSquares from them, and passes xIsNext, squares and onPlay to Board. handlePlay cuts the history at the current move before adding the new board. The move list is history.map(…) with key={move}. The run shows the game before any move: an empty board and one button. In the browser, play a few moves, then click Go to game start or a move.

main.jsx

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const xIsNext = currentMove % 2 === 0;
  const currentSquares = history[currentMove];

  function handlePlay(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
  }

  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

function calculateWinner(squares) {
  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]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

styles.css

.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}

.status {
  margin-bottom: 10px;
}

.board-row {
  display: flex;
}

.square {
  width: 2.5rem;
  height: 2.5rem;
  margin: 0 -1px -1px 0;
  border: 1px solid #999;
  background: #fff;
  font-size: 1.5rem;
  font-weight: bold;
}

Run it with

npm run dev

Output

<!-- #root -->
<div id="root"><div class="game"><div class="game-board"><div class="status">Next player: X</div><div class="board-row"><button class="square"></button><button class="square"></button><button class="square"></button></div><div class="board-row"><button class="square"></button><button class="square"></button><button class="square"></button></div><div class="board-row"><button class="square"></button><button class="square"></button><button class="square"></button></div></div><div class="game-info"><ol><li><button>Go to game start</button></li></ol></div></div></div>
  • history starts with one empty board, so the list has one button.
  • Board's Fragment leaves no element: the status and the rows sit directly in game-board.
  • key does not appear in the HTML; React keeps it for itself.
  • Nothing was printed before the snapshot, so React gave no key warning.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 3

Lift the state into Game

The starter is the game from lesson B1.4, with a new default export, Game, around it. Move the state up. Game keeps xIsNext (starting true) and history (starting [Array(9).fill(null)]); the current board is history[history.length - 1]. handlePlay(nextSquares) appends the board with setHistory([...history, nextSquares]) and flips xIsNext. Board takes xIsNext, squares and onPlay as props, has no useState any more, and calls onPlay(nextSquares) instead of its setters. Keep export in front of Board: the tests render it on its own.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Board starts like this: export function Board({ xIsNext, squares, onPlay }) {, without the two useState lines.

  2. Hint 2

    In Board's handleClick, replace setSquares(nextSquares) and setXIsNext(!xIsNext) with onPlay(nextSquares).

  3. Hint 3

    In Game: <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

export function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{/* the move list comes in exercise 2 */}</ol>
      </div>
    </div>
  );
}

function calculateWinner(squares) {
  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]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

export function Board() {
  // TODO: take xIsNext, squares and onPlay as props instead of keeping state here.
  const [xIsNext, setXIsNext] = useState(true);
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  // TODO: keep xIsNext and history (a list of squares arrays) here, and pass
  // xIsNext, the latest squares and handlePlay down to Board.
  return (
    <div className="game">
      <div className="game-board">
        <Board />
      </div>
      <div className="game-info">
        <ol>{/* the move list comes in exercise 2 */}</ol>
      </div>
    </div>
  );
}

function calculateWinner(squares) {
  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]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Game, {Board} from './main.jsx';

const squares = () => screen.getAllByRole('button').slice(0, 9).map((button) => button.textContent);
const status = () => screen.getByText(/^(Next player|Winner):/).textContent;
const play = async (user, positions) => {
  for (const i of positions) await user.click(screen.getAllByRole('button')[i]);
};
const EMPTY = Array(9).fill(null);

test('The game still plays: X wins the top row', async () => {
  const user = userEvent.setup();
  render(<Game />);
  await play(user, [0, 3, 1, 4, 2]);
  assert.equal(status(), 'Winner: X', 'after X fills the top row the status reads ' + JSON.stringify(status()));
});

test('Board shows the squares prop', () => {
  render(<Board xIsNext={true} squares={['X', 'O', null, null, null, null, null, null, null]} onPlay={() => {}} />);
  assert.deepEqual(squares().slice(0, 3), ['X', 'O', ''], 'with squares X, O, null the first row reads ' + JSON.stringify(squares().slice(0, 3)));
});

test('Board shows whose turn it is from the xIsNext prop', () => {
  render(<Board xIsNext={false} squares={EMPTY} onPlay={() => {}} />);
  assert.equal(status(), 'Next player: O', 'with xIsNext={false} the status reads ' + JSON.stringify(status()));
});

test('Board calls onPlay with the next squares', async () => {
  const user = userEvent.setup();
  let played = null;
  render(<Board xIsNext={true} squares={EMPTY} onPlay={(next) => { played = next; }} />);
  await play(user, [4]);
  assert.deepEqual(played, [null, null, null, null, 'X', null, null, null, null], 'a click on the middle square called onPlay with ' + JSON.stringify(played));
});

test('Board keeps no state of its own', async () => {
  const user = userEvent.setup();
  render(<Board xIsNext={true} squares={EMPTY} onPlay={() => {}} />);
  await play(user, [0]);
  assert.equal(squares()[0], '', 'when onPlay ignores the move, the clicked square must stay empty; it shows ' + JSON.stringify(squares()[0]));
});

test('React prints no warnings', async () => {
  const warnings = [];
  const original = console.error;
  console.error = (...args) => warnings.push(args.join(' '));
  try {
    const user = userEvent.setup();
    render(<Game />);
    await play(user, [0, 4]);
  } finally {
    console.error = original;
  }
  assert.deepEqual(warnings, [], 'console.error while rendering and playing');
});

styles.css

.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}

.status {
  margin-bottom: 10px;
}

.board-row {
  display: flex;
}

.square {
  width: 2.5rem;
  height: 2.5rem;
  margin: 0 -1px -1px 0;
  border: 1px solid #999;
  background: #fff;
  font-size: 1.5rem;
  font-weight: bold;
}

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Exercise 2 of 3

Show the past moves and travel back

Add time travel. Game gets a second state, currentMove, starting at 0, and shows history[currentMove]. Turn history into the move list with map: one <li key={move}> per board, with a button that says Go to game start for move 0 and Go to move #1, #2 … for the others, and calls jumpTo(move). jumpTo sets currentMove and sets xIsNext to nextMove % 2 === 0. handlePlay keeps only history.slice(0, currentMove + 1) before adding the new board, and sets currentMove to the new last index.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    const moves = history.map((squares, move) => { … return <li key={move}><button onClick={() => jumpTo(move)}>{description}</button></li>; }); then <ol>{moves}</ol>.

  2. Hint 2

    function jumpTo(nextMove) { setCurrentMove(nextMove); setXIsNext(nextMove % 2 === 0); }

  3. Hint 3

    In handlePlay: const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]; setHistory(nextHistory); setCurrentMove(nextHistory.length - 1);

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[currentMove];

  function handlePlay(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
    setXIsNext(!xIsNext);
  }

  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
    setXIsNext(nextMove % 2 === 0);
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

function calculateWinner(squares) {
  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]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  // TODO: add currentMove, and show history[currentMove] instead of the last board.
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    // TODO: keep only history.slice(0, currentMove + 1) before adding nextSquares,
    // and point currentMove at the new last entry.
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }

  // TODO: write jumpTo(nextMove), and turn history into one <li> with a button per entry.

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{/* TODO: the moves */}</ol>
      </div>
    </div>
  );
}

function calculateWinner(squares) {
  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]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Game from './main.jsx';

const squares = () => screen.getAllByRole('button').slice(0, 9).map((button) => button.textContent);
const status = () => screen.getByText(/^(Next player|Winner):/).textContent;
const play = async (user, positions) => {
  for (const i of positions) await user.click(screen.getAllByRole('button')[i]);
};
const moves = () => within(screen.getByRole('list')).queryAllByRole('button').map((button) => button.textContent);
const jump = async (user, name) => user.click(within(screen.getByRole('list')).getByRole('button', {name}));

test('The list starts with Go to game start', () => {
  render(<Game />);
  assert.deepEqual(moves(), ['Go to game start'], 'before any move the list reads ' + JSON.stringify(moves()));
});

test('Each move adds a button', async () => {
  const user = userEvent.setup();
  render(<Game />);
  await play(user, [0, 1]);
  assert.deepEqual(moves(), ['Go to game start', 'Go to move #1', 'Go to move #2'], 'after two moves the list reads ' + JSON.stringify(moves()));
});

test('Go to game start shows the empty board', async () => {
  const user = userEvent.setup();
  render(<Game />);
  await play(user, [0, 1]);
  await jump(user, 'Go to game start');
  assert.deepEqual(squares(), ['', '', '', '', '', '', '', '', ''], 'after the jump to the start the squares read ' + JSON.stringify(squares()));
  assert.equal(status(), 'Next player: X', 'after the jump to the start the status reads ' + JSON.stringify(status()));
});

test('Go to move #1 shows the board after the first move', async () => {
  const user = userEvent.setup();
  render(<Game />);
  await play(user, [0, 1, 2]);
  await jump(user, 'Go to move #1');
  assert.deepEqual(squares().slice(0, 3), ['X', '', ''], 'after the jump to move #1 the top row reads ' + JSON.stringify(squares().slice(0, 3)));
  assert.equal(status(), 'Next player: O', 'after the jump to move #1 the status reads ' + JSON.stringify(status()));
});

test('A move after a jump replaces the later moves', async () => {
  const user = userEvent.setup();
  render(<Game />);
  await play(user, [0, 1, 2]);
  await jump(user, 'Go to move #1');
  await play(user, [4]);
  assert.deepEqual(moves(), ['Go to game start', 'Go to move #1', 'Go to move #2'], 'after jumping to move #1 and playing again the list reads ' + JSON.stringify(moves()));
  assert.deepEqual(squares().slice(0, 5), ['X', '', '', '', 'O'], 'the board reads ' + JSON.stringify(squares().slice(0, 5)) + ' in its first five squares');
});

test('React prints no warnings', async () => {
  const warnings = [];
  const original = console.error;
  console.error = (...args) => warnings.push(args.join(' '));
  try {
    const user = userEvent.setup();
    render(<Game />);
    await play(user, [0, 4]);
  } finally {
    console.error = original;
  }
  assert.deepEqual(warnings, [], 'console.error while rendering and playing');
});

styles.css

.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}

.status {
  margin-bottom: 10px;
}

.board-row {
  display: flex;
}

.square {
  width: 2.5rem;
  height: 2.5rem;
  margin: 0 -1px -1px 0;
  border: 1px solid #999;
  background: #fff;
  font-size: 1.5rem;
  font-weight: bold;
}

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Exercise 3 of 3

Remove the redundant state

Someone deleted the line in jumpTo that set xIsNext, and now the turn goes wrong after a jump: after two moves and a jump to move #1, the status says X is next, although it is O's turn. Do not put the line back. xIsNext is true exactly when currentMove is even, so remove its useState and every setXIsNext call, and compute it while rendering instead.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run it: play two moves, then click Go to move #1, and read the status.

  2. Hint 2

    Delete const [xIsNext, setXIsNext] = useState(true); and the setXIsNext(!xIsNext) line in handlePlay.

  3. Hint 3

    After the useState for currentMove, add const xIsNext = currentMove % 2 === 0;

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const xIsNext = currentMove % 2 === 0;
  const currentSquares = history[currentMove];

  function handlePlay(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
  }

  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

function calculateWinner(squares) {
  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]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[currentMove];

  function handlePlay(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
    setXIsNext(!xIsNext);
  }

  function jumpTo(nextMove) {
    // TODO: xIsNext follows from currentMove; remove its state instead of setting it here.
    setCurrentMove(nextMove);
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

function calculateWinner(squares) {
  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]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Game from './main.jsx';

const squares = () => screen.getAllByRole('button').slice(0, 9).map((button) => button.textContent);
const status = () => screen.getByText(/^(Next player|Winner):/).textContent;
const play = async (user, positions) => {
  for (const i of positions) await user.click(screen.getAllByRole('button')[i]);
};
const moves = () => within(screen.getByRole('list')).queryAllByRole('button').map((button) => button.textContent);
const jump = async (user, name) => user.click(within(screen.getByRole('list')).getByRole('button', {name}));

test('After a jump to the start, X is next', async () => {
  const user = userEvent.setup();
  render(<Game />);
  await play(user, [0]);
  await jump(user, 'Go to game start');
  assert.equal(status(), 'Next player: X', 'after one move and a jump to the start the status reads ' + JSON.stringify(status()));
});

test('After a jump to move #1, O is next', async () => {
  const user = userEvent.setup();
  render(<Game />);
  await play(user, [0, 1]);
  await jump(user, 'Go to move #1');
  assert.equal(status(), 'Next player: O', 'after two moves and a jump to move #1 the status reads ' + JSON.stringify(status()));
});

test('The move after a jump uses the right mark', async () => {
  const user = userEvent.setup();
  render(<Game />);
  await play(user, [0, 1]);
  await jump(user, 'Go to move #1');
  await play(user, [4]);
  assert.equal(squares()[4], 'O', 'after a jump to move #1 the next move placed ' + JSON.stringify(squares()[4]));
});

test('A full game still finds the winner', async () => {
  const user = userEvent.setup();
  render(<Game />);
  await play(user, [0, 3, 1, 4, 2]);
  assert.equal(status(), 'Winner: X', 'after X fills the top row the status reads ' + JSON.stringify(status()));
  assert.equal(moves().length, 6, 'after five moves the list holds ' + moves().length + ' buttons');
});

test('React prints no warnings', async () => {
  const warnings = [];
  const original = console.error;
  console.error = (...args) => warnings.push(args.join(' '));
  try {
    const user = userEvent.setup();
    render(<Game />);
    await play(user, [0, 4]);
  } finally {
    console.error = original;
  }
  assert.deepEqual(warnings, [], 'console.error while rendering and playing');
});

styles.css

.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}

.status {
  margin-bottom: 10px;
}

.board-row {
  display: flex;
}

.square {
  width: 2.5rem;
  height: 2.5rem;
  margin: 0 -1px -1px 0;
  border: 1px solid #999;
  background: #fff;
  font-size: 1.5rem;
  font-weight: bold;
}

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Common mistakes

A list of moves without keys

export default function Moves() {
  const history = [[null], ['X'], ['X', 'O']];

  const moves = history.map((squares, move) => (
    <li>
      <button>Go to move #{move}</button>
    </li>
  ));

  return <ol>{moves}</ol>;
}

What React prints

Each child in a list should have a unique "key" prop.

Why, and the fix

The list still renders, but React warns, and the course runner counts any warning as a failed run. Give the outermost element the callback returns a key: <li key={move}>. The tutorial shows an older wording of the same warning ("Each child in an array or iterator…"); React 19 prints the sentence quoted here.

Writing the key in quotes

export default function Moves() {
  const history = [[null], ['X'], ['X', 'O']];

  const moves = history.map((squares, move) => (
    <li key="move">
      <button>Go to move #{move}</button>
    </li>
  ));

  return <ol>{moves}</ol>;
}

What React prints

Encountered two children with the same key, `move`.

Why, and the fix

key="move" gives every item the same text, move, so the keys are no longer unique, and React warns. Use curly braces to pass the variable: key={move}.

Calling handlePlay where Board needs a function

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
  }

  return <Board xIsNext={true} squares={currentSquares} onPlay={handlePlay(nextSquares)} />;
}

function calculateWinner(squares) {
  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]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

What React prints

ReferenceError: nextSquares is not defined

Why, and the fix

onPlay={handlePlay(nextSquares)} tries to call handlePlay while Game renders, and nextSquares exists only inside Board's handleClick, so the first render stops with Uncaught ReferenceError. Pass the function itself, onPlay={handlePlay}; Board calls it with the new board when a player moves.

React in the browser: React 19.3.0 (development build) and React Testing Library, MIT, served from this site; JSX is compiled by TypeScript 6.0.3, Apache-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Lift the state up again, into Game

To go back in time, something must remember every board, not only the current one. Game, the new top-level component, keeps history: an array of squares arrays, starting as [Array(9).fill(null)]. Board loses its useState calls and receives three props: xIsNext, squares and onPlay. When a player moves, Board copies squares, writes X or O into the copy and calls onPlay(nextSquares); Game's handlePlay appends it with setHistory([...history, nextSquares]). Board is now fully controlled by Game: it shows only what it receives. This works because every move made a copy, so the old boards are still intact.

A list of moves, each with a key

history.map((squares, move) => …) turns the boards into list items: the button for move 0 says Go to game start, the others Go to move #1, #2 and so on. React needs a key on each item of a list to tell the items apart when the list changes. Without one it warns: Each child in a list should have a unique "key" prop. Here key={move} is safe, because moves are never reordered, deleted or inserted in the middle. key is special: React reads it, and the component never receives it as a prop. Lesson B2.5 covers keys in depth.

Time travel and one less state

A second state, currentMove, says which board to show: currentSquares = history[currentMove]. Each button calls jumpTo(move), which sets currentMove. When a player moves after jumping back, the later moves no longer belong to the game, so handlePlay keeps only history.slice(0, currentMove + 1), adds the new board and points currentMove at it. Finally, xIsNext is true exactly when currentMove is even, so it does not need state of its own: const xIsNext = currentMove % 2 === 0. Redundant state can drift out of sync; a value computed while rendering cannot.

Sources

Last reviewed October 5, 2026