Course: Section

Get Started with Modern React: Learn by Doing

Episode: Title

S03・V17: Time Travel (Part 1 – Storing a History of Moves)

Date Created: July 17th, 2019
Last Updated: 27 days ago

Objectives
  1. Store the history of moves in state.
  2. Move the history state into the top-level component.
  3. Make code adjustments for lifting state up.
Watch Video
Duration: 7m 9s

Storing a History of Moves

We are treating our squares array as immutable, since we make a new copy of it after every move. “Time travel” means the ability to jump back and forth between different versions of the squares array. We will need to store every past version of the squares array during a game, to make time travel possible.

Shape of History

We will store past squares arrays in another array called history. The history array represents all board states, from the first move to the last move.

First, we will show you what shape the history array has.

// Shape of `history`
const history = [];

The history array contains elements that are objects. Each object in the array has a key called squares, which has a value which is an array representing all nine squares on the board at a certain point in time. Before the first move, we have the first element of the history array:

// Shape of `history`
const history = [
  // Before first move  { squares: Array(9).fill(null) },];

After the first move, made by player X; for example, on the square with index 4, we have:

// Shape of `history`
const history = [
  // Before first move
  { squares: Array(9).fill(null) },
  // After first move  { squares: [null,null,null,null,'X',null,null,null,null] },];

Let’s say, on the next move, player O clicks on the square with index 8. And so on, for further moves:

// Shape of `history`
const history = [
  // Before first move
  { squares: Array(9).fill(null) },
  // After first move
  { squares: [null,null,null,null,'X',null,null,null,null] },
  // After second move  { squares: [null,null,null,null,'X',null,null,null,'O'] },  // ...];

With this history array, we can access the latest move using the following expression:

// Access the latest move in history
history[history.length - 1];

Adding History State

Now, we need to decide which component should own the history state. We want the top-level Game component to display a list of past moves. It will need access to the history to do that, so we will place the history state in the top-level Game component. Initially, the history array will reflect the state of the game before the first move.

const Game = () => {
  const initialHistory = [    { squares: Array(9).fill(null) },  ];
  return (
    <div className="game">
      <Board />
    </div>
  );
};

The useState hook gets and sets the history state.

const Game = () => {
  const initialHistory = [
    { squares: Array(9).fill(null) },
  ];
  const [history, setHistory] = useState(initialHistory);
  return (
    <div className="game">
      <Board />
    </div>
  );
};

The Game component will control all the state for the game. We can now move the xIsNext state from the Board to the Game component.

const Game = () => {
  const initialHistory = [
    { squares: Array(9).fill(null) },
  ];
  const [history, setHistory] = useState(initialHistory);
  const [xIsNext, setXIsNext] = useState(true);
  return (
    <div className="game">
      <Board />
    </div>
  );
};

We can also remove the squares state from the Board component. We have now given the Game component full control over the Board’s data, and that will allow the Game to instruct the Board to render previous moves from the history. Next, we’ll have the Board component receive the squares array via props from the Game component. In addition, we will pass down an onClickEvent handler from the Game component to the Board component via props.

const Board = props => {  const handleClickEvent = i => {
    const newSquares = [...squares];

    // Return early if winner declared or square filled
    const winnerDeclared = Boolean(calculateWinner(newSquares));
    const squareFilled = Boolean(newSquares[i]);
    if (winnerDeclared || squareFilled) {
      return;
    }

    newSquares[i] = xIsNext ? 'X' : 'O';
    setSquares(newSquares);
    setXIsNext(!xIsNext);
  }

  const winner = calculateWinner(squares);
  const status = winner ?
    `Winner: ${winner}` :
    `Next player: ${xIsNext ? 'X' : 'O'}`;

  const renderSquare = i => {
    return (
      <Square
        value={props.squares[i]}        onClickEvent={() => props.onClickEvent(i)}      />
    );
  };

Moving ‘winner’ Up

Next, we will move the winner calculation and status from the Board component to the Game component.

const Game = () => {
  const initialHistory = [
    { squares: Array(9).fill(null) },
  ];
  const [history, setHistory] = useState(initialHistory);
  const [xIsNext, setXIsNext] = useState(true);

  const winner = calculateWinner(squares);  const status = winner ?    `Winner: ${winner}` :    `Next player: ${xIsNext ? 'X' : 'O'}`;
  return (
    <div className="game">
      <Board />
    </div>
  );
};

Now, we will reference the currentMove in the history array. We will calculate the winner from the squares array of the currentMove.

const Game = () => {
  const initialHistory = [
    { squares: Array(9).fill(null) },
  ];
  const [history, setHistory] = useState(initialHistory);
  const [xIsNext, setXIsNext] = useState(true);

  const currentMove = history[history.length - 1];  const winner = calculateWinner(currentMove.squares);  const status = winner ?
    `Winner: ${winner}` :
    `Next player: ${xIsNext ? 'X' : 'O'}`;

  return (
    <div className="game">
      <Board />
    </div>
  );
};

We can now pass the squares array to the Board as props. We will also pass an onClickEvent handler as props to the Board. The passed-in function handleClickEvent is the same as the handler currently in the Board component, which will be moved into the Game component shortly.

const Game = () => {
  const initialHistory = [
    { squares: Array(9).fill(null) },
  ];
  const [history, setHistory] = useState(initialHistory);
  const [xIsNext, setXIsNext] = useState(true);

  const currentMove = history[history.length - 1];
  const winner = calculateWinner(currentMove.squares);
  const status = winner ?
    `Winner: ${winner}` :
    `Next player: ${xIsNext ? 'X' : 'O'}`;

  return (
    <div className="game">
      <Board 
        squares={currentMove.squares}        onClickEvent={i => handleClickEvent(i)}      />
    </div>
  );
};

Moving ‘status’ Up

Now, we will move the displayed status from the Board component to the Game component.

const Game = () => {
  const initialHistory = [
    { squares: Array(9).fill(null) },
  ];
  const [history, setHistory] = useState(initialHistory);
  const [xIsNext, setXIsNext] = useState(true);

  const currentMove = history[history.length - 1];
  const winner = calculateWinner(currentMove.squares);
  const status = winner ?
    `Winner: ${winner}` :
    `Next player: ${xIsNext ? 'X' : 'O'}`;

  return (
    <div className="game">
      <Board 
        squares={currentMove.squares}
        onClickEvent={i => handleClickEvent(i)}
      />
      <div className="status">{status}</div>    </div>
  );
};

Moving ‘handleClickEvent’ Up

Next, we will move the entire handleClickEvent function from the Board component to the Game component.

const Game = () => {
  const initialHistory = [
    { squares: Array(9).fill(null) },
  ];
  const [history, setHistory] = useState(initialHistory);
  const [xIsNext, setXIsNext] = useState(true);

  const handleClickEvent = i => {    const newSquares = [...squares];    // Return early if winner declared or square filled    const winnerDeclared = Boolean(calculateWinner(newSquares));    const squareFilled = Boolean(newSquares[i]);    if (winnerDeclared || squareFilled) {      return;    }    newSquares[i] = xIsNext ? 'X' : 'O';    setSquares(newSquares);    setXIsNext(!xIsNext);  }
  const currentMove = history[history.length - 1];
  const winner = calculateWinner(currentMove.squares);
  const status = winner ?
    `Winner: ${winner}` :
    `Next player: ${xIsNext ? 'X' : 'O'}`;

  return (
    <div className="game">
      <Board 
        squares={currentMove.squares}
        onClickEvent={i => handleClickEvent(i)}
      />
      <div className="status">{status}</div>
    </div>
  );
};

Updating ‘handleClickEvent’

We will now make some changes to the handleClickEvent function. We will reference the currentMove in the history state array. The squares array is now taken off the currentMove object.

  const handleClickEvent = i => {
    const currentMove = history[history.length - 1];    const newSquares = [...currentMove.squares];
    // Return early if winner declared or square filled
    const winnerDeclared = Boolean(calculateWinner(newSquares));
    const squareFilled = Boolean(newSquares[i]);
    if (winnerDeclared || squareFilled) {
      return;
    }

    newSquares[i] = xIsNext ? 'X' : 'O';
    setSquares(newSquares);
    setXIsNext(!xIsNext);
  }

Let’s now move to the line in our code which sets the squares. We will delete this line and add a newMove object. Using the immutable approach, we can now append this newMove object to a copy of the history state array, which we will call newHistory. Finally, we will update the history state using the setHistory function.

  const handleClickEvent = i => {
    const currentMove = history[history.length - 1];
    const newSquares = [...currentMove.squares];

    // Return early if winner declared or square filled
    const winnerDeclared = Boolean(calculateWinner(newSquares));
    const squareFilled = Boolean(newSquares[i]);
    if (winnerDeclared || squareFilled) {
      return;
    }

    newSquares[i] = xIsNext ? 'X' : 'O';
    const newMove = { squares: newSquares };    const newHistory = [...history, newMove];    setHistory(newHistory);    setXIsNext(!xIsNext);
  }

Code Snapshot

src/index.js
import React, { useState } from 'react';
import ReactDOM from 'react-dom';

import './index.css';

const Square = props => {
  return (
    <button
      className="square"
      onClick={props.onClickEvent}
    >
      {props.value}
    </button>
  );
};

const Board = props => {
  const renderSquare = i => {
    return (
      <Square
        value={squares[i]}
        onClickEvent={() => handleClickEvent(i)}
      />
    );
  };

  return (
    <div>
      <div className="board-row">
        {renderSquare(0)}
        {renderSquare(1)}
        {renderSquare(2)}
      </div>
      <div className="board-row">
        {renderSquare(3)}
        {renderSquare(4)}
        {renderSquare(5)}
      </div>
      <div className="board-row">
        {renderSquare(6)}
        {renderSquare(7)}
        {renderSquare(8)}
      </div>
    </div>
  );
};

const Game = () => {
  const initialHistory = [
    { squares: Array(9).fill(null) },
  ];
  const [history, setHistory] = useState(initialHistory);
  const [xIsNext, setXIsNext] = useState(true);

  const handleClickEvent = i => {
    const currentMove = history[history.length - 1];
    const newSquares = [...currentMove.squares];

    // Return early if winner declared or square filled
    const winnerDeclared = Boolean(calculateWinner(newSquares));
    const squareFilled = Boolean(newSquares[i]);
    if (winnerDeclared || squareFilled) {
      return;
    }

    newSquares[i] = xIsNext ? 'X' : 'O';
    const newMove = { squares: newSquares };
    const newHistory = [...history, newMove];
    setHistory(newHistory);
    setXIsNext(!xIsNext);
  };

  const currentMove = history[history.length - 1];
  const winner = calculateWinner(currentMove.squares);
  const status = winner ?
    `Winner: ${winner}` :
    `Next player: ${xIsNext ? 'X' : 'O'}`;

  return (
    <div className="game">
      <Board 
        squares={currentMove.squares}
        onClickEvent={i => handleClickEvent(i)}
      />
      <div className="status">{status}</div>
    </div>
  );
};

ReactDOM.render(
  <Game />,
  document.getElementById('root')
);

function calculateWinner(squares) {
  /*
  0 1 2
  3 4 5
  6 7 8
  */
  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 line of lines) {
    const [a, b, c] = line;
    if (
      squares[a] &&
      squares[a] === squares[b] &&
      squares[a] === squares[c]
    ) {
      return squares[a];
    }
  }

  return null;
}

Summary

We introduced history state, which stored the squares arrays of each past move. We placed history state in the top-level Game component. And, we made adjustments to deal with lifting the state up.

Next Up…

In the next video, we will show the past moves in our game.