Build a Tic-Tac-Toe Game with React Hooks (React Beginner Projects Part 1)

Uci Lasmana
14 min readJul 13, 2023

--

If you’re a beginner, looking for a simple app that can help you learn useState, useRef, and useEffect then you've come to the right place.

However, I recommend that you first learn from this guide, React Hooks: A Companion Guide for React Beginner Projects. This guide will help you gain a basic knowledge of React Hooks.

Here we go!

First, I wanna show you the user interface of the app that we will build in this tutorial.

Tic-Tac-Toe UI

This is what the app will look like on widescreen. If you want to check the app you can visit this link, https://ucilasmana.github.io/tic-tac-toe/

From this UI we can get into details about what kind of functions we need to build. Let’s see!

  1. The board consists of nine grids to put X or O based on the player’s turn.
  2. Under the board, we have the Previous Step button to get back to the previous step.
  3. The Reset Board button to clear the board
  4. And on the left board, we have scoreboards to count scores for both players.

So we already know the details and let’s get to work!

Create a New React Project

  • Open your command prompt/terminal to run this command:
npx create-react-app my-app-name
  • Wait for the whole process. After it’s done you can navigate the directory to the project folder we have just created. And then start it.
cd my-app-name

npm start
  • You can see the project live on localhost:3000

Create Components

  • Before we start creating the board, we need to create a new folder “Components” in the src folder. And then we create JSX and CSS files for Board and Scoreboard, just like this:
Directory Structure

In case, if you’re wondering about the difference between .jsx and .js files, .jsx stands for JavaScript XML. JSX lets you write HTML directly within the JavaScript code. It will help you to convert HTML tags into react elements without any createElement() or appendChild() methods. The process of creating elements will be much easier. Plus, in .jsx files you can use the Emmet plugin to help you write HTML tags faster.

Set CSS Rules

  • You can copy and paste these CSS Rules if you need a fast pass.

index.css

@import url('https://fonts.googleapis.com/css2?family=Mochiy+Pop+One&family=Nunito:wght@700;800;900&display=swap');

html{
scroll-behavior: smooth;
--font-mochi:'Mochiy Pop One', sans-serif;
--font-nunito: 'Nunito', sans-serif;
--yellow-color:#f5d001;
--red-color:#df391f;
--light-blue-color:#a6d8e2;
--dark-blue-color:rgb(0,110, 255);
--light-btn-back:rgb(228, 253, 246);
}

body {
margin: 0;
background-color: var(--light-blue-color);
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

button {
cursor: pointer;
border: none;
outline: none;
user-select: none;
-webkit-user-select: none;
}

app.css

.App{
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
}
@media screen and (min-width: 900px) {
.App{
flex-direction: row;
justify-content: space-evenly;
}
}

Board.css

.board-square{
display: grid;
grid-template-columns: auto auto auto;
gap: 10px;
}
.box{
font-size: 6em;
border: 1px solid rgba(245, 245, 245, 0.89);
box-shadow:0px 0px 2px rgba(204, 204, 204, 0.644);
border-radius: 10%;
width: 8.5rem;
height: 8.5rem;
text-align: center;
font-weight: 900;
line-height: 0;
font-family: var(--font-mochi);
color:white;
}
.x {
background-color:var(--red-color)

}
.o{

background-color:var(--yellow-color) ;
}
.default{
background-color: var(--default-color);
}
.box:hover{
box-shadow:0px 0px 15px #888;
}
.board-action{
display:flex;
flex-direction: row;
justify-content: space-evenly;
}
.board-action button{
margin: 20px 0;
width: 8.5rem;
border-radius: 10px;
color:white;
font-size: 1.1rem;
padding: 0.5rem ;
font-family:var(--font-nunito);
box-shadow:0px 0px 5px rgba(9, 20, 71, 0.301);
}
.board-action .reset-btn{
background-color: var(--dark-blue-color);
}
.board-action .prev-btn{
background-color: rgb(2, 150, 69);
}
.board-action .reset-btn:hover{
color: rgb(0, 95, 173);
background-color: var(--light-btn-back)
}
.board-action .prev-btn:hover{
color: rgb(0, 136, 91);
background-color: var(--light-btn-back)
}
.modal{
display: none;
position: absolute;
color: var(--yellow-color);
font-family: var(--font-mochi);
text-align: center;
font-size: 2.5em;
z-index: 10;
background-color: rgba(225, 240, 255, 0.89);
height: 100%;
width: 100%;
}
.modal .container{
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%,-50%);
color:rgb(4, 56, 124)
}
.modal button{
font-family: var(--font-nunito);
background-color:rgba(255, 255, 255, 0.966);
border: 1px solid rgb(196, 196, 196);
color:rgb(122, 122, 122) ;
font-weight: 700;
font-size: 0.4em;
box-shadow:0px 0px 5px rgba(88, 88, 88, 0.178);
height: 30px;
border-radius: 10px;
}
.modal button:hover{
background-color:rgb(4, 56, 124) ;
color: rgba(255, 255, 255, 0.966);
}
@media screen and (max-width: 900px) {
.board-action button{
margin: 40px 0;
}
.box{
font-size: 4.5em;
width: 6rem;
height: 6rem;
}
}
@media screen and (max-width: 500px) {
.board-square{
gap: 6px;
}
.board-action{
flex-direction: column;
align-items: center;
justify-content: space-around;
margin-top:30px;
}
.board-action button{
margin: 15px 0 0 0 ;
width: 7rem;
font-size: 0.8rem;
}
}
@media screen and (max-width: 420px) {
.box{
font-size: 3.5em;
width: 4.5rem;
height: 4.5rem;
}
}
@media screen and (max-width: 320px) {
.box{
font-size: 2.5em;
width: 4rem;
height: 4rem;
}
}

ScoreBoard.css

.scoreboard {
text-align: center;
display: flex;
gap: 30px;
}
.player {
user-select: none;
-webkit-user-select: none;
background-color: rgb(255, 255, 255);
box-shadow: 0px 0px 8px rgba(136, 136, 136, 0.815);
border-radius: 15px;
}
.player div{
border-radius: 15px 25px 25px 15px;
}
.x-score {
background-color: var(--red-color);
}
.o-score {
background-color: var(--yellow-color);
}

.player h3{
font-family: var(--font-nunito);
margin: 0;
font-size: 1.1em;
color: white;
text-shadow: 0.5px 0.5px rgb(184, 184, 184);
}

.player-score{
font-family: var(--font-mochi);
line-height: 1;
font-size: 2.2em;
margin: 0;
text-shadow: 1px 1px gray;

}
.player-score._x {
color: var(--red-color);
}
.player-score._o{
color: var(--yellow-color);
}

@media screen and (min-width: 900px) {
.player div{
border-radius: 15px 15px 0 0;
}
.player h3{
padding: 20px 30px;
font-size: 1.45em;
}
.player-score{
font-size: 3em;
padding: 20px 30px 30px 30px;
}
}
@media screen and (max-width: 900px) {
.player {
display: flex;

}
.scoreboard{
margin:10% 0 20% 0;
}
.player h3{
padding: 10px;
font-size: 1em;
}
.player-score{
padding: 10px 18px 10px 18px;
font-size: 1.6em;
}
}
@media screen and (max-width: 500px) {
.scoreboard{
margin:0 0 20% 0;
}
}
@media screen and (max-width: 430px) {
.player div{
border-radius: 10px;
}
.player h3{
padding: 9px 6px 5px 6px;
font-size: 0.83em;
}
.player-score{
font-size: 1.3em;
padding: 8px 10px 10px 10px;
}
}
@media screen and (max-width: 300px) {

.player h3{
font-size: 0.79em;
}
.player-score{
font-size: 1.1em;
}
}

Alright, it’s time to build the board now.

Board. jsx

To create the board, we must create nine grids to put X/O based on the player’s turn.

import {useRef} from 'react'
import "./Board.css"

const Board = () => {

const board = useRef(Array(9).fill(null))

return (
<>
<div className='board'>
<div className='board-square'>
{board.current.map((value, index) => {
return <button key={index} className={`box
${value === null ? "default"
: value === "X" ? "x":"o"
}`}</button>
})}
</div>
</div>
</>
)
}

export default Board
  • From the code above, you can see I use useRef to define the board variable. The board variable was assigned an array that has 9 null elements. We use this array to store the players’ actions (X/O)
const board = {current : Array(9).fill(null)}
  • We access the board by using:
board.current

Since the app we’re building right now is a simple tic-tac-toe, I prefer useRef over useState to define the board variable, so I can show you the difference between them.

useRef returns a ref object which is mutable unlike state. Mutable object is an object whose value can be changed after it’s created, like arrays and objects. This is how we gonna update the board value:

if(index===boxIndex) 
{
if(player) {
board.current[boxIndex]= "X"
}
else{
board.current[boxIndex]= "O"
}
}

But in state, we should treat an array as an immutable object when we store it in state, just like other primitive types (numbers, strings, booleans, etc). So, when we want to update the value, we need to create a new one or make a copy of an existing one, and then set the state to use the new array. You can look at this code below:

setBoard(board.map((value, index) => { 
if(index===boxIndex)
{
if(player) {
return "X"
}
else{
return "O"
}
}
else
{
return value
}
}))

If you’re wondering about the UI elements, how do we update the UI because useRef doesn’t trigger a re-render, well we can just depend on the other states variable later.

After creating the board, we need to create a function that handles the players' actions.

import {useState, useRef} from 'react'
...
const Board = () => {

...
const [player, setPlayer] = useState(true);

const handleBoxClick = (boxIndex) => {

if(player)
{
board.current[boxIndex]= "X"
}
else{
board.current[boxIndex]="O"
}


setPlayer(!player)

}

return (
...
<div className='board-square'>
{board.current.map((value, index) => {
return <button key={index} className={...}
onClick= {() => value === null && handleBoxClick(index)}>
{value}</button>
})}
</div>

)
}
  • When a player clicks one of the grids, the system will check if the grid is still empty or not.
<button key={index} className={...}
onClick= {() => value === null && handleBoxClick(index)}>
{value}</button>
  • If the grid is empty, the click will trigger handleBoxClick function which has the grid’s index as an argument. This function will return an action (X/O) based on the player’s turn. But if the grid is not empty, the function will not work.
  • The argument we passed will be used as an index to access the board’s state array to store the player’s action.
  • To determine the player’s action, we create a new state variable called player with “true” as the initial state.
const [player, setPlayer] = useState(true);
  • Then we use the setPlayer function to update the state and trigger a re-render. So the UI elements will get updated too.
setPlayer(!player)

We already have the handleBoxClick function, now we need win conditions for the players.

import React, {useState, useEffect, useRef} from 'react'


const Board = () => {

...
const modal = useRef(null)
const [message, setMessage] = useState(null)
const [gameOver, setGameOver]=useState(false)
const [scores, setScores] = useState({xScore:0, oScore:0})

const WIN_CONDITIONS = useRef([
[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]
])

useEffect(() => {

if(gameOver)
{
board.current=Array(9).fill(null)
setGameOver(!gameOver)
}

}, [gameOver]);


const checkWinner = (board) =>{

for (let i = 0; i<WIN_CONDITIONS.current.length; i++)
{
const[x, y, z] = WIN_CONDITIONS.current[i]

if(board[x] && board[x] === board[y] && board[y] === board[z])
{
return board[x];
}
}
}

const handleBoxClick = (boxIndex) => {
...

if(!board.current.includes(null))
{
modal.current.style.setProperty("display", "block")
setMessages("Tie!")
}

const winner = checkWinner(board.current);

if(winner){
if(winner === "O"){
let {oScore} = scores;
oScore +=1
setScores({
...scores, oScore})
}
else{
let {xScore} = scores;
xScore +=1
setScores({
...scores, xScore})
}
modal.current.style.setProperty("display", "block")
setMessages("Player "+winner+" Wins!")
}
}

const closeModal = () => {
modal.current.style.setProperty("display", "none")
setGameOver(!gameOver)
}

return (
<>
...
<div ref={modal} className='modal'>
<div className='container'>
<h1>{messages}</h1>
<br/>
<button onClick={()=>closeModal()}>close</button>
</div>
</div>
</>
)
}

export default Board
  • modal ref is for accessing the modal div element directly in the DOM which will be used to show a message from message state. The message state will keep a message about the game's end condition, whether there is a winner or the game is tied.
<div ref={modal} className='modal'>
<div className='container'>
<h1>{messages}</h1>
<br/>
<button onClick={()=>closeModal()}>close</button>
</div>
</div>
  • This modal will be hidden first, it only will be displayed when the game meets the end condition.
modal.current.style.setProperty("display", "block")
  • To hide the modal again, the player needs to click the close button and it will trigger closeModal function. This function will trigger setGameOver function, which will trigger useEffect hook, and it will reset the board.
  • gameOver state will keep the status of the game, if it’s false the game is still going, and if it’s true the game is over.
useEffect(() => {

if(gameOver)
{
board.current=Array(9).fill(null)
setGameOver(!gameOver)
}

}, [gameOver]);
  • scores state is a state object, which has two properties, which are xScore to keep the Player X scores and oScore to keep the Player O scores.
const [scores, setScores] = useState({xScore:0, oScore:0})
  • To determine the winner, we need to create win conditions. We use a multidimensional array to help us gather all of the conditions in one variable, like this :
const WIN_CONDITIONS = useRef([
[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]
])

This is the illustration of the board :

[0][1][2]
[3][4][5]
[6][7][8]

  • To check if the player’s actions meet the win conditions, we created checkWinner function.
const checkWinner = (board) =>{

for (let i = 0; i<WIN_CONDITIONS.current.length; i++)
{
const[x, y, z] = WIN_CONDITIONS.current[i]

if(board[x] && board[x] === board[y] && board[y] === board[z])
{
return board[x];
}
}
}
  • In this function, we’re gonna destruct data from WIN_CONDITIONS array into variables x, y, and z. We use these variables as an index for the board.

The illustration of how checkWinner function will work:

let’s extract the array value from WIN_CONDITIONS.current[0]

[x,y,z]=[0,1,2]

We use x, y, and z variable values as an index to check if the board’s grids have the same value

board[0] === board[1] && board[1] === board[2]

What if the board’s grids value are still null, the comparison will be true.

[null][null][null]

When the comparison is true, the next line will be executed. This is not what we want, we need to make sure that one of the grids has real value not null

board[0]&&board[0] === board[1] && board[1] === board[2]

If this comparison turns out to be true, then the function will return the board’s grid value. This value will be used to determine which players will score.

  • The return value from checkWinner function will be assigned to the winner variable
const winner = checkWinner(board.current);

if(winner){
if(winner === "O"){
let {oScore} = scores;
oScore +=1
setScores({
...scores, oScore})
}
else{
let {xScore} = scores;
xScore +=1
setScores({
...scores, xScore})
}
modal.current.style.setProperty("display", "block")
setMessages("Player "+winner+" Wins!")
}
  • If the winner’s value is “O” then we’re gonna extract the oScore, but if the winner’s value is “X” then we’re gonna extract the xScore.
  • Since the scores state is a state object with two properties. we can’t just add the new score to the previous score
setScores(score+1)
//this will remove our score state's properties,
//because the next value will be a regular number without any properties
  • We’re gonna use spread syntax to prevent this from happening. With spread syntax, we can update the score we want and still keep the other score
setScores({...scores, oScore})
setScores({...scores, xScore})
  • But, if checkWinner function didn’t return any value and there is no empty board’s grid anymore, we’re gonna call it to tie.
if(!board.current.includes(null))
{
modal.current.style.setProperty("display", "block")
setMessages("Tie!")
}

Since we already built the board, now it’s time to create two buttons under the board, which are Previous Step button and Reset Board button.

...

const Board = () => {

...
const [history, setHistory]=useState([]);
const [sameIndex, setSameIndex]=useState(false)

useEffect(() => {

if(sameIndex)
{
setHistory(history=>history)
setSameIndex(!sameIndex)
}
if(gameOver)
{
...
setHistory([])
}
}, [sameIndex, gameOver]);

...

const handleBoxClick = (boxIndex) => {
if(player === true)
{
board.current[boxIndex]= "X"
}
else{
board.current[boxIndex]="O"
}

setHistory([boxIndex, ...history])

...
}
....

const handlePrevClick=()=>{
for(let indexBoard = 0; indexBoard<9; indexBoard++){
if(history[0]===indexBoard)
{
setSameIndex(!sameIndex)
history.splice(0, 1);
board.current.splice(indexBoard, 1, null);
return
}
}
}

return (
<>
<div className='board'>
...
<div className="board-action">
<button className='prev-btn' onClick= {() => handlePrevClick()}>Previous Step</button>
<button className="reset-btn" onClick={()=> setGameOver(!gameOver) }>Reset Board</button>
</div>
</div>
...
</>
)
}

export default Board
  • Previous Step button is an undo button. This button helps the players to get back to the previous steps. This button will trigger the handlePrevClick function.
<button className='prev-btn' onClick= {() => handlePrevClick()}>Previous Step</button>
  • To make handlePrevClick function works, we need to create new states, which are history state and sameIndex state
const [history, setHistory]=useState([]);
const [sameIndex, setSameIndex]=useState(false)
  • Every time the players click one of the grids, the history state will keep the index from the grid, and it will always be placed as the first value in the history state array.
setHistory([boxIndex, ...history])
  • To get the last actions from the players, we need to compare the first value from history state with the board’s index.
  • When the history [0] value matches indexBoard, sameIndex value will be turned to true.
  • Now, we can remove the value from history[0] and change the value from board[indexBoard] to null.
const handlePrevClick=()=>{
for(let indexBoard = 0; indexBoard<9; indexBoard++){
if(history[0]===indexBoard)
{
setSameIndex(!sameIndex)
history.splice(0, 1);
board.current.splice(indexBoard, 1, null);
return
}
}
}

The splice() is a method to adds and/or removes array elements. This method will overwrite the original array.

splice(start, deleteCount, item0, item1, /* … ,*/ itemN)

The start argument is an index position to start changing the array.

The deleteCount is a number of elements in the array to remove from the start.

The item is an elements to add to the array, beginning from the start.

The deleteCount and the item are optionals. If you do not specify the deleteCount and the item, splice will only remove elements from the array, beginning from the start.

  • When sameIndex value changes, useEffect hook will get triggered. The history state and the board ref will be updated with the result value from the split function, and sameIndex value will turn to false again.
useEffect(() => {

if(sameIndex)
{
setHistory(history=>history)
setSameIndex(!sameIndex)
}
...
}, [sameIndex, ...]);
  • Since we don’t need the history state to become one of the dependencies, we set new history value with a functional update to bypass the linter warning in code editor (if you have one).
setHistory(history=>history)

This is because every reactive value used by the useEffect’s code must be declared as a dependency, and it will cause the useEffect hook to re-run if the history state value changes, which we don’t want. Therefore, it’s better to set history to its current value. Since the state doesn’t actually change, React won’t trigger a re-render.

  • When the game is over, we want the history state value back to its initial value, so we set the state like this :
setHistory([])
  • And the Reset Board button will trigger setGameOver function when it gets clicked, and it will trigger useEffect hook and the board will be reset
<button className="reset-btn" onClick={()=> setGameOver(!gameOver) }>Reset Board</button>

ScoreBoard.jsx

  • Here, we import scoreboard component into Board.jsx. We can use the scoreboard as a child component to pass props, which are the scores.
...
import ScoreBoard from './ScoreBoard';

const Board = () => {
...

return (
<>
<div className="score">
<ScoreBoard scores={scores}/>
</div>
...
</>
)
}

export default Board
  • Now, we can use scores props inside the ScoreBoard component. But we need to extract xScore and oScore from the scores props before we use them to show the players’ scores.
import "./ScoreBoard.css"

const ScoreBoard = ({scores}) => {

const {xScore, oScore} = scores;

return (
<div className='scoreboard'>

<div className="player">
<div className='x-score'>
<h3>Player X</h3>
</div>
<h1 className="player-score _x">{xScore}</h1>
</div>

<div className="player">
<div className=' o-score'><h3>Player O</h3></div>
<h1 className="player-score _o">{oScore}</h1>
</div>

</div>
)
}

export default ScoreBoard

App.js

  • Here we are in the last step, to use the board component, we need to import the component into App.js
import React from 'react';
import './App.css';
import Board from './Components/Board'

function App() {
return (
<div className="App">
<Board/>
</div>
);
}
export default App;

From this tutorial, we learned how to use useState, useRef , and useEffect. In the next tutorial, we’ll learn how to use useContext and useReducer by building a to-do list app, you can find it here.

You can check the full codes of this tic-tac-toe game in this GitHub repository: https://github.com/ucilasmana/tic-tac-toe.

I hope this can be helpful and you’re not bored with this. Have a good day!

--

--

Uci Lasmana

Sharing knowledge not only helps me grow as a developer but also contributes to the growth of fellow developers. Let’s grow together!