Get Started with Modern React: Learn by Doing
S03・V17: Time Travel (Part 1 – Storing a History of Moves)
- Store the history of moves in state.
- Move the history state into the top-level component.
- Make code adjustments for lifting state up.
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
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.