Building a game
You will build a small tic-tac-toe game during this tutorial. This tutorial does assume existing React knowledge. The techniques you’ll learn in the tutorial are fundamental to building any React app, and fully understanding it will give you a deep understanding of React and Zustand.This tutorial is crafted for those who learn best through hands-on experience and want to swiftly create something tangible. It draws inspiration from React’s tic-tac-toe tutorial.
- Setup for the tutorial will give you a starting point to follow the tutorial.
- Overview will teach you the fundamentals of React: components, props, and state.
- Completing the game will teach you the most common techniques in React development.
- Adding time travel will give you a deeper insight into the unique strengths of React.
What are you building?
In this tutorial, you’ll build an interactive tic-tac-toe game with React and Zustand. You can see what it will look like when you’re finished here:Building the board
Create the Square component
Let’s start by creating the
Square component, which will be a building block for our Board component. This component will represent each square in our game.The Square component should take value and onSquareClick as props. It should return a <button> element, styled to look like a square. The button displays the value prop, which can be 'X', 'O', or null, depending on the game’s state. When the button is clicked, it triggers the onSquareClick function passed in as a prop, allowing the game to respond to user input.Here’s the code for the Square component:Create the Board component
Let’s move on to creating the Board component, which will consist of 9 squares arranged in a grid. This component will serve as the main playing area for our game.The This Board component sets up the basic structure for our game board by arranging nine squares in a 3x3 grid. It positions the squares neatly, providing a foundation for adding more features and handling player interactions in the future.
Board component should return a <div> element styled as a grid. The grid layout is achieved using CSS Grid, with three columns and three rows, each taking up an equal fraction of the available space. The overall size of the grid is determined by the width and height properties, ensuring that it is square-shaped and appropriately sized.Inside the grid, we place nine Square components, each with a value prop representing its position. These Square components will eventually hold the game symbols ('X' or 'O') and handle user interactions.Here’s the code for the Board component:Lifting state up
EachSquare component could maintain a part of the game’s state. To check for a winner in a tic-tac-toe game, the Board component would need to somehow know the state of each of the 9 Square components.
How would you approach that? At first, you might guess that the Board component needs to ask each Square component for that Square’s component state. Although this approach is technically possible in React, we discourage it because the code becomes difficult to understand, susceptible to bugs, and hard to refactor. Instead, the best approach is to store the game’s state in the parent Board component instead of in each Square component. The Board component can tell each Square component what to display by passing a prop, like you did when you passed a number to each Square component.
To collect data from multiple children, or to have two or more child components communicate with each other, declare the shared state in their parent component instead. The parent component can pass that state back down to the children via props. This keeps the child components in sync with each other and with their parent.
Board component so that it declares a state variable named squares that defaults to an array of 9 nulls corresponding to the 9 squares:
Array(9).fill(null) creates an array with nine elements and sets each of them to null. The useGameStore declares a squares state that’s initially set to that array. Each entry in the array corresponds to the value of a square. When you fill the board in later, the squares array will look like this:
value prop that will either be 'X', 'O', or null for empty squares.
Next, you need to change what happens when a Square component is clicked. The Board component now maintains which squares are filled. You’ll need to create a way for the Square component to update the Board’s component state. Since state is private to a component that defines it, you cannot update the Board’s component state directly from Square component.
Instead, you’ll pass down a function from the Board component to the Square component, and you’ll have Square component call that function when a square is clicked. You’ll start with the function that the Square component will call when it is clicked. You’ll call that function onSquareClick:
Now you’ll connect the onSquareClick prop to a function in the Board component that you’ll name handleClick. To connect onSquareClick to handleClick you’ll pass an inline function to the onSquareClick prop of the first Square component:
handleClick function inside the Board component to update the squares array holding your board’s state.
The handleClick function should take the index of the square to update and create a copy of the squares array (nextSquares). Then, handleClick updates the nextSquares array by adding X to the square at the specified index (i) if is not already filled.
Note how in
handleClick function, you call .slice() to create a copy of the squares array instead of modifying the existing array.Taking turns
It’s now time to fix a major defect in this tic-tac-toe game: the'O's cannot be used on the board.
You’ll set the first move to be 'X' by default. Let’s keep track of this by adding another piece of state to the useGameStore hook:
xIsNext (a boolean) will be flipped to determine which player goes next and the game’s state will be saved. You’ll update the Board’s handleClick function to flip the value of xIsNext:
Declaring a winner or draw
Now that the players can take turns, you’ll want to show when the game is won or drawn and there are no more turns to make. To do this you’ll add three helper functions. The first helper function calledcalculateWinner that takes an array of 9 squares, checks for a winner and returns 'X', 'O', or null as appropriate. The second helper function called calculateTurns that takes the same array, checks for remaining turns by filtering out only null items, and returns the count of them. The last helper called calculateStatus that takes the remaining turns, the winner, and the current player ('X' or 'O'):
calculateWinner(squares) in the Board component’s handleClick function to check if a player has won. You can perform this check at the same time you check if a user has clicked a square that already has a 'X' or and 'O'. We’d like to return early in both cases:
'Winner: X' or 'Winner: O'. To do that you’ll add a status section to the Board component. The status will display the winner or draw if the game is over and if the game is ongoing you’ll display which player’s turn is next:
Adding time travel
As a final exercise, let’s make it possible to “go back in time” and revisit previous moves in the game. If you had directly modified the squares array, implementing this time-travel feature would be very difficult. However, since you usedslice() to create a new copy of the squares array after every move, treating it as immutable, you can store every past version of the squares array and navigate between them.
You’ll keep track of these past squares arrays in a new state variable called history. This history array will store all board states, from the first move to the latest one, and will look something like this:
Lifting state up, again
Next, you will create a new top-level component calledGame to display a list of past moves. This is where you will store the history state that contains the entire game history.
By placing the history state in the Game component, you can remove the squares state from the Board component. You will now lift the state up from the Board component to the top-level Game component. This change allows the Game component to have full control over the Board’s component data and instruct the Board component to render previous turns from the history.
First, add a Game component with export default and remove it from Board component. Here is what the code should look like:
useGameStore hook to track the history of moves:
[Array(9).fill(null)] creates an array with a single item, which is itself an array of 9 null values.
To render the squares for the current move, you’ll need to read the most recent squares array from the history state. You don’t need an extra state for this because you already have enough information to calculate it during rendering:
handlePlay function inside the Game component that will be called by the Board component to update the game. Pass xIsNext, currentSquares and handlePlay as props to the Board component:
Board component fully controlled by the props it receives. To do this, we’ll modify the Board component to accept three props: xIsNext, squares, and a new onPlay function that the Board component can call with the updated squares array when a player makes a move.
Board component is now fully controlled by the props passed to it by the Game component. To get the game working again, you need to implement the handlePlay function in the Game component.
What should handlePlay do when called? Previously, the Board component called setSquares with an updated array; now it passes the updated squares array to onPlay.
The handlePlay function needs to update the Game component’s state to trigger a re-render. Instead of using setSquares, you’ll update the history state variable by appending the updated squares array as a new history entry. You also need to toggle xIsNext, just as the Board component used to do.
Game component, and the UI should be fully working, just as it was before the refactor. Here is what the code should look like at this point:
Showing the past moves
Since you are recording the tic-tac-toe game’s history, you can now display a list of past moves to the player. You already have an array ofhistory moves in store, so now you need to transform it to an array of React elements. In JavaScript, to transform one array into another, you can use the Array .map() method:
You’ll use map to transform your history of moves into React elements representing buttons on the screen, and display a list of buttons to jump to past moves. Let’s map over the history in the Game component:
jumpTo function, you need the Game component to keep track of which step the user is currently viewing. To do this, define a new state variable called currentMove, which will start at 0:
jumpTo function inside Game component to update that currentMove. You’ll also set xIsNext to true if the number that you’re changing currentMove to is even.
handlePlay function in the Game component, which is called when you click on a square.
- If you “go back in time” and then make a new move from that point, you only want to keep the history up to that point. Instead of adding
nextSquaresafter all items in the history (using the Array.concat()method), you’ll add it after all items inhistory.slice(0, currentMove + 1)to keep only that portion of the old history. - Each time a move is made, you need to update
currentMoveto point to the latest history entry.
Game component to render the currently selected move, instead of always rendering the final move:
Final cleanup
If you look closely at the code, you’ll see thatxIsNext is true when currentMove is even and false when currentMove is odd. This means that if you know the value of currentMove, you can always determine what xIsNext should be.
There’s no need to store xIsNext separately in the state. It’s better to avoid redundant state because it can reduce bugs and make your code easier to understand. Instead, you can calculate xIsNext based on currentMove:
xIsNext state declaration or the calls to setXIsNext. Now, there’s no chance for xIsNext to get out of sync with currentMove, even if you make a mistake while coding the components.
Wrapping up
Congratulations! You’ve created a tic-tac-toe game that:- Lets you play tic-tac-toe,
- Indicates when a player has won the game or when is drawn,
- Stores a game’s history as a game progresses,
- Allows players to review a game’s history and see previous versions of a game’s board.