Play with it on CodeSandbox
This repository contains the tic-tac-toe game from the official
React Tutorial: Intro to React, but with hooks & reducers.
It's inspired by the YouTube video Trying React Hooks for the first time with Dan Abramov,
at the end of which (1:01:36) he mentiones a few things you can implement:
Seperate the game logic and time traveling into two independent parts:
- move the game logic into a reducer
- create a custom hook that uses a reducer to manage the history state and takes care of time travel
That's what this repository is for.
There are four implementation variants of the game, each in one of the src/index.*.js
files:
index .no-reducer .no-timetravel.js |
index .no-timetravel.js |
index .no-reducer.js |
index .varA .js |
index .varB .js |
|
---|---|---|---|---|---|
Based on | cdpn.io/LyyXgK | cdpn.io/LyyXgK | cdpn.io/gWWZgR | cdpn.io/gWWZgR | cdpn.io/gWWZgR |
React Hooks | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
Reducer | ✖️ | ✔️ | ✖️ | ✔️ | ✔️ |
Time Travel | ✖️ | ✖️ | ✔️ | ✔️ | ✔️ |
The first three are straightforward to write - the last two (index.varA.js
and index.varB.js
) are of interest, because they include a custom Hook that manages time travel (useTimeTravel
).
tl;dr
index.varA.js
's custom Hook is a wrapper for your game logic: You handle game-state-transitions through it. That saves you from dispatching actions to your game-reducer as well as your timetravel-reducer, but you still have to deal with thehistory
array (e.g.const current = history[history.length - 1];
)- In
index.varB.js
's you dispatch actions to your game-reducer and timetravel-reducer, but you don't have to deal with thehistory
anymore.
This variant uses a custom Hook that is a wrapper around the reducer of the game logic.
function Game() {
[...]
// custom Hook
const [{ history, stepNumber }, dispatchTimeTravel] = useTimeTravel(
gameLogicReducer,
initialBoardState
);
[...]
}
To get the current state of the game and to change it (e.g. if someone clicks on a square), you always work with the custom Hook.
function Game() {
[...]
const current = history[stepNumber]; // history and stepNumber are returned by your custom hook
[...]
function handleClick(i) {
[...]
// the payload is the action to the reducer of the game (which is in this case just the index)
dispatchTimeTravel({ type: "HISTORY_ADD", payload: i });
}
function jumpTo(step) {
dispatchTimeTravel({ type: "HISTORY_JUMPTO", payload: step });
}
[...]
}
The gameLogicReducer
is only called inside the timeTravelReducer()
to get the new state of the game.
function timeTravelReducer(state, action) {
switch (action.type) {
case "HISTORY_ADD":
const gameLogicAction = action.payload;
let { gameLogicReducer, history, stepNumber } = state;
let gameState = history[stepNumber];
gameState = gameLogicReducer(gameState, gameLogicAction);
[...]
}
}
That's why the code in our Game()
function component is very similar to the final code of the tutorial: We are still working with the history object.
function Game() {
[...]
const current = history[stepNumber]; // history and stepNumber are returned by your custom hook
[...]
const winner = calculateWinner(current.squares);
[...]
status = "Next player: " + (current.xIsNext ? "X" : "O");
[...]
<Board squares={current.squares} onClick={i => handleClick(i)} />
[...]
}
This variant uses a custom Hook that is used additionally to the reducer of the game
function Game() {
[...]
const [gameState, dispatchGame] = useReducer(
gameLogicReducer,
initialBoardState
);
const [stepNumber, dispatchTimeTravel] = useTimeTravel(dispatchGame,
{ type: "PLAY_RESET" } // an action to reset the game is required
);
[...]
}
Instead of calling the game reducer inside the time travel reducer, you call both directly in your Game()
component.
function Game() {
[...]
function handleClick(i) {
[...]
const gameAction = { type: "PLAY_MOVE", payload: i };
dispatchGame(gameAction);
dispatchTimeTravel({ type: "HISTORY_ADD", payload: gameAction });
}
}
For this to work, the game reducer has to implement a second type of state change, to reset the game.
function gameLogicReducer(state, action) {
[...]
case "PLAY_RESET":
return {
squares: new Array(9).fill(null),
xIsNext: true
};
[...]
}
That's because the time travel reducer doesn't have full control over the game state: He can only dispatch action and unlike in index.varA.js
can't just replace the state of the game.
When you jump back in time, the time travel reducer has to reset the game and replay it until the chosen move is reached.
function timeTravelReducer(state, action) {
[...]
case "HISTORY_JUMPTO":
[...]
dispatchGame(actionReset); // actionReset === { type: "PLAY_RESET }
actionHistory = actionHistory.slice(0, stepNumber);
actionHistory.forEach(action => dispatchGame(action));
[...]
}
}
The upside of this is, that you can just use the game state and don't have to mess with an history
array in your Game
component.
function Game() {
[...]
const winner = calculateWinner(gameState.squares);
[...]
const winner = calculateWinner(gameState.squares);
[...]
const winner = calculateWinner(gameState.squares);
[...]
}