Build your own unbeatable Tic Tac Toe with React Hooks and Styled Components

react styled components javascript

Having been working with React for a few years already, I realised that I have only used the framework for developing websites and mobile applications. With the addition of the Hooks, I thought it would be interesting to make a small game, to get a bit more into how React lifecycle works. For the game choice, I decided to convert a jQuery version of Tic Tac Toe, I built a few years ago, to React, which proved more challenging in the end than I expected. The final version of the game can be found here and the code is available on Github, in case you'd like to dive straight into it.

Setting up 

For setting up the game we'll use create-react-app. In addition to React, we'll use Styled components, a CSS framework papercss, which will give the game cool minimalistic styling (my website uses papercss as well), and React-modal to display the game results. We'll start by creating empty project and installing necessary dependencies. 

npx create-react-app tic_tac_toe
cd tic_tac_toe
npm i styled-components papercss react-modal

After the project is setup, we can begin by modifying App.js to include the main game components and papercss styles.

// App.js

import React from "react";
import styled from "styled-components";
import TicTacToe from "./TicTacToe";
import "papercss/dist/paper.min.css";

function App() {
  return (
    <Main>
      <TicTacToe />
    </Main>
  );
}

const Main = styled.main`
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
`;

export default App;

This will center the game component on the screen. Not relevant elements, like footer, are omitted so we can focus on the most important parts. The next step is to create the actual TicTacToe component. Since the size of the app is relatively small, we are gonna keep all the files directly in the src folder.

First let's start with adding some of the game's constants to a separate constants.js.

// Dimensions of the board (3x3 squares), game outcomes and players, 
// and dimensions for the board squares, in pixels.

export const DIMS = 3;
export const DRAW = 0;
export const PLAYER_X = 1;
export const PLAYER_O = 2;
export const SQUARE_DIMS = 100;

Now in the newly created TicTacToe.js we can start setting up and rendering the game's grid.

import React, { useState } from "react";
import styled from "styled-components";
import { DIMS, PLAYER_X, PLAYER_O, SQUARE_DIMS } from "./constants";

const arr = new Array(DIMS ** 2).fill(null);

const TicTacToe = () => {
  const [grid, setGrid] = useState(arr);
  const [players, setPlayers] = useState({
    human: PLAYER_X,
    computer: PLAYER_O
  });

  const move = (index, player) => {
    setGrid(grid => {
      const gridCopy = grid.concat();
      gridCopy[index] = player;
      return gridCopy;
    });
  };

  const humanMove = index => {
    if (!grid[index]) {
      move(index, players.human);
    }
  };

  return (
    <Container dims={DIMS}>
      {grid.map((value, index) => {
        const isActive = value !== null;

        return (
          <Square
            key={index}
            onClick={() => humanMove(index)}
          >
            {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>}
          </Square>
        );
      })}
    </Container>
  );
};

const Container = styled.div`
  display: flex;
  justify-content: center;
  width: ${({ dims }) => `${dims * (SQUARE_DIMS + 5)}px`};
  flex-flow: wrap;
  position: relative;
`;

const Square = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: ${SQUARE_DIMS}px;
  height: ${SQUARE_DIMS}px;
  border: 1px solid black;

  &:hover {
    cursor: pointer;
  }
`;

const Marker = styled.p`
  font-size: 68px;
`;

export default TicTacToe;

First we start by importing all the necessary dependencies and declaring the default array for the grid. Note that we are using JavaScript's new exponentiation operator, added in ES2016, and Array.prototype.fill() from ES2015/ES6, to create an array of length 9 and fill it with null values. It's declared outside of the component so it doesn't get re-created when the component re-renders. Instead of creating a multidimensional array and then recursively rendering it, we are going to render a one dimensional array and limit its width with CSS.

width: ${({ dims }) => `${dims * (SQUARE_DIMS + 5)}px`}; is styled components' way to pass a variable to component, which can also be written as width: ${(props) => `${props.dims * (SQUARE_DIMS + 5)}px`}; Here we limit the container's width by 3 squares of 100 pixels (plus a few px to account for borders) and set flex-flow: wrap , which will push the extra squares to the next line and so on, in the end creating a 3 x 3 squares grid. After running npm start and making a few moves, we can validate that our grid functions properly.

Looks good, however it's not too exciting since we haven't setup the computer's moves. We will fix it by adding computerMove function.

// utils.js

// Get random integer in a range min-max
export const getRandomInt = (min, max) => {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min + 1)) + min;
};

// TicTacToe.js

// ...

const computerMove = () => {
  let index = getRandomInt(0, 8);
  while (grid[index]) {
    index = getRandomInt(0, 8);
  }
  move(index, players.computer);
};

const humanMove = index => {
  if (!grid[index]) {
    move(index, players.human);
    computerMove();
  }
};

Now the game is more interactive. After human player's turn, computerMove function is called, which basically makes a move to a random empty square on the board. Note that we have also added a utils.js file to our project, where all the helpers, like the one used to get a random number in a range, will be stored.

Of course the game is still far from perfect and has a number of issues. We will start improving it by adding three game states Not started, In progress and Over. When the game is in the first state, we will show a select player screen, second state will render the board and allow players to make moves, and final state will declare the game's outcome. 

// constants.js

export const GAME_STATES = {
  notStarted: "not_started",
  inProgress: "in_progress",
  over: "over"
};

 Now we can use them in our component to render different "screens".

// utils.js

import { PLAYER_O, PLAYER_X } from "./constants";

export const switchPlayer = player => {
  return player === PLAYER_X ? PLAYER_O : PLAYER_X;
};

// TicTacToe.js

const TicTacToe = () => {
//...
const [players, setPlayers] = useState({ human: null, computer: null });
const [gameState, setGameState] = useState(GAME_STATES.notStarted);

//...
const choosePlayer = option => {
  setPlayers({ human: option, computer: switchPlayer(option) });
  setGameState(GAME_STATES.inProgress);
};

return gameState === GAME_STATES.notStarted ? (
    <Screen>
      <Inner>
        <ChooseText>Choose your player</ChooseText>
        <ButtonRow>
          <button onClick={() => choosePlayer(PLAYER_X)}>X</button>
          <p>or</p>
          <button onClick={() => choosePlayer(PLAYER_O)}>O</button>
        </ButtonRow>
      </Inner>
    </Screen>
  ) : (
    <Container dims={DIMS}>
      {grid.map((value, index) => {
        const isActive = value !== null;

        return (
          <Square
            key={index}
            onClick={() => humanMove(index)}
          >
            {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>}
          </Square>
        );
      })}
    </Container>
  );
};

const ButtonRow = styled.div`
  display: flex;
  width: 150px;
  justify-content: space-between;
`;

const Screen = styled.div``;

const Inner = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-bottom: 30px;
`;

const ChooseText = styled.p``;

Adding effects hook

The above changes allow to choose a player. However, since we don't check whose move it currently is, the human player can make several moves out of turn. To fix it we will introduce turn based moves, assigning the player whose turn is next to nextMove.

//TicTacToe.js

 const [nextMove, setNextMove] = useState(null);

//...

const humanMove = index => {
  if (!grid[index] && nextMove === players.human) {
    move(index, players.human);
    setNextMove(players.computer);
  }
};

useEffect(() => {
  let timeout;
  if (
    nextMove !== null &&
    nextMove === players.computer &&
    gameState !== GAME_STATES.over
  ) {
    // Delay computer moves to make them more natural
    timeout = setTimeout(() => {
      computerMove();
    }, 500);
  }
  return () => timeout && clearTimeout(timeout);
}, [nextMove, computerMove, players.computer, gameState]);


const choosePlayer = option => {
  setPlayers({ human: option, computer: switchPlayer(option) });
  setGameState(GAME_STATES.inProgress);
  setNextMove(PLAYER_X); // Set the Player X to make the first move
};

Quite a few things are going on here. Firstly we're adding nextMove to the useEffect's dependency array, so when it changes, the code inside the effect is run. For this to work, inside our humanMove function, instead of calling computerMove, we will set computer as the one that makes the next move. Additionally we'll check that it's actually human player's turn before allowing to make a move. As an enhancement, a slight timeout to make computer moves non-instantaneous, is added. Have to also remember to remove the timeout in the effect's cleanup function. Apart from nextMove, we also need to track other variables from the component scope that are accessed inside the effect. Considering that computerMove is a function here and will be recreated on every render, we will use useCallback hook to memoize it and prevent from changing unless any of its dependencies change. For more in depth look, this article provides an excellent overview of the main caveats of the effect hook.

const computerMove = useCallback(() => {
  let index = getRandomInt(0, 8);
  while (grid[index]) {
    index = getRandomInt(0, 8);
  }

  move(index, players.computer);
  setNextMove(players.human);

}, [move, grid, players]);

Since we're tracking  move function here, we'll need to memoize it as well.

//TicTacToe.js

const move = useCallback(
  (index, player) => {
    if (player && gameState === GAME_STATES.inProgress) {
      setGrid(grid => {
        const gridCopy = grid.concat();
        gridCopy[index] = player;
        return gridCopy;
      });
    }
  },
  [gameState]
);

The players can make their moves now and the flow of the game already seems quite natural. However if you run the game to the end, i.e. fill all of the available squares, it will be stuck in the infinite loop. The reasons is that the while loop in the computerMove does not have a termination condition after there are no more empty squares left on the grid. If so far it seems that after we solve one issue, a few new ones turn up, hold on in there, we're quite close to fix them all! 

Adding Board class

If you look at the code closely, you'll see that we're not actually setting the game state to over at any point of the game. But before doing that, we need a way to find the game's winner. This seems like a good opportunity to make a separate Board class, which would encapsulate all non-render related board logic.

// Board.js

import { DIMS, DRAW } from "./constants";

export default class Board {
  constructor(grid) {
    this.grid = grid || new Array(DIMS ** 2).fill(null);
  }

  // Collect indices of empty squares and return them
  getEmptySquares = (grid = this.grid) => {
    let squares = [];
    grid.forEach((square, i) => {
      if (square === null) squares.push(i);
    });
    return squares;
  };

  isEmpty = (grid = this.grid) => {
    return this.getEmptySquares(grid).length === DIMS ** 2;
  };

  getWinner = (grid = this.grid) => {
    const winningCombos = [
      [0, 1, 2],
      [3, 4, 5],
      [6, 7, 8],
      [0, 3, 6],
      [1, 4, 7],
      [2, 5, 8],
      [0, 4, 8],
      [2, 4, 6]
    ];
    let res = null;
    winningCombos.forEach((el, i) => {
      if (
        grid[el[0]] !== null &&
        grid[el[0]] === grid[el[1]] &&
        grid[el[0]] === grid[el[2]]
      ) {
        res = grid[el[0]];
      } else if (res === null && this.getEmptySquares(grid).length === 0) {
        res = DRAW;
      }
    });
    return res;
  };

  clone = () => {
    return new Board(this.grid.concat());
  };
}

The class itself is pretty straightforward. We add a method to get the indices of all empty squares, a utility method to check if the board is empty, ability to make a copy of the board, and finally, getWinner method, which will return the game's result by checking if the current state of the board has any of the winning combinations, hardcoded in the method. Apart from initialising the board with an empty grid, we'll also allow its methods to accept a grid as an optional parameter, so we can apply them to the grid from our game component.

Alright, so now we have a way to get the game's winner. Let's use it to signify when the game is over and at the same time we'll add a method to actually set the game result to the state, so we can show it after. It makes sense to check if the game has reached the end after each move is made, so we'll introduce another useEffect hook to track these changes.

//TicTactToe.js

import Board from "./Board";

const board = new Board();

const TicTacToe = () => {
  //...
  const [winner, setWinner] = useState(null);

  //...

  useEffect(() => {
    const winner = board.getWinner(grid);
    const declareWinner = winner => {
      let winnerStr;
      switch (winner) {
        case PLAYER_X:
          winnerStr = "Player X wins!";
          break;
        case PLAYER_O:
          winnerStr = "Player O wins!";
          break;
        case DRAW:
        default:
          winnerStr = "It's a draw";
      }
      setGameState(GAME_STATES.over);
      setWinner(winnerStr);
    };

    if (winner !== null && gameState !== GAME_STATES.over) {
      declareWinner(winner);
    }
  }, [gameState, grid, nextMove]);

}

Now we can render the result message together with a New game button, which will basically reset the grid state and set the game to not started.

//TicTacToe.js

const startNewGame = () => {
  setGameState(GAME_STATES.notStarted);
  setGrid(arr);
};

switch (gameState) {
  case GAME_STATES.notStarted:
  default:
    return (
      <Screen>
        <Inner>
          <ChooseText>Choose your player</ChooseText>
          <ButtonRow>
            <button onClick={() => choosePlayer(PLAYER_X)}>X</button>
            <p>or</p>
            <button onClick={() => choosePlayer(PLAYER_O)}>O</button>
          </ButtonRow>
        </Inner>
      </Screen>
    );
  case GAME_STATES.inProgress:
    return (
      <Container dims={DIMS}>
        {grid.map((value, index) => {
          const isActive = value !== null;

          return (
            <Square
              key={index}
              onClick={() => humanMove(index)}
            >
              {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>}
            </Square>
          );
        })}
      </Container>
    );
  case GAME_STATES.over:
    return (
      <div>
        <p>{winner}</p>
        <button onClick={startNewGame}>Start over</button>
      </div>
    );
}

Introducing Minimax

With these changes we now have a proper Tic Tac Toe game. One thing is still missing though: computer moves at random, which makes it quite easy to beat. We can tip the situation to the other extreme by introducing Minimax algorithm for calculating the best moves for computer. Properly implemented this will make the game unbeatable, the best human player can count on is a draw. I will not go too much in-depth about the inner workings of the algorithm, there have been plenty of articles written about it, available online. Basically what Minimax does is assigns value to every move, based on the final game outcome. The move with the highest score is selected as the best move. In order to do that the algorithm needs to recursively calculate all the moves for a current state of the board. Considering that in Tic Tac Toe the number of possible moves is relatively low, the algorithm runs quite fast.

// constants.js

export const SCORES = {
  1: 1,
  0: 0,
  2: -1
};

// minimax.js

import { SCORES } from "./constants";
import { switchPlayer } from "./utils";

export const minimax = (board, player) => {
  const mult = SCORES[player];
  let thisScore;
  let maxScore = -1;
  let bestMove = null;

  if (board.getWinner() !== null) {
    return [SCORES[board.getWinner()], 0];
  } else {
    for (let empty of board.getEmptySquares()) {
      let copy = board.clone();
      copy.makeMove(empty, player);
      thisScore = mult * minimax(copy, switchPlayer(player))[0];

      if (thisScore >= maxScore) {
        maxScore = thisScore;
        bestMove = empty;
      }
    }

    return [mult * maxScore, bestMove];
  }
};

In order for the algorithm to work, we need to add makeMove method to our board class, which will put current player on the board.

// Board.js

makeMove = (square, player) => {
  if (this.grid[square] === null) {
    this.grid[square] = player;
  }
};

The reason why we're not just using move function from the TicTacToe component is because triggering it inside the loop of minimax will change the component's state and cause numerous re-renders, which will quickly result in the stack overflow. 

Finally we can actually get the computer opponent to make "smart" moves. 

// TicTacToe.js

import {minimax} from './minimax';

//...

const computerMove = useCallback(() => {
  const board = new Board(grid.concat());
  const index = board.isEmpty(grid)
        ? getRandomInt(0, 8)
        : minimax(board, players.computer)[1];

  if (!grid[index]) {
    move(index, players.computer);
    setNextMove(players.human);
  }
}, [move, grid, players]);

It is important to pass a copy of the grid to the Board constructor, so the minimax doesn't change the actual grid used in the TicTacToe component.

concat called on an array without arguments will return a copy of that array. The same effect can be achieved with grid.slice() or using JS array spread syntax: [...grid].

Next, if the board is empty when it is computer's turn, meaning that computer is making the first move, we're gonna make a random move for the computer to greatly speed up minimax calculation. 

Adding difficulty levels

At this point the base game is pretty much ready, however we can still improve it a bit. While the random computer version was too easy, the minimax one is too hard, basically not letting the human player to win. We can combine them and add a "medium" level, where (roughly) half of the moves will be random and the other half minimax. While we're at it, let's also add already developed "easy" and "difficult" levels. For this to work, we'll introduce mode to the component state. The player will be able to select a desired game mode in the beginning of every game and computerMove function has to be modified to accommodate this selection.

// constants.js

// ...
export const GAME_MODES = {
  easy: "easy",
  medium: "medium",
  difficult: "difficult"
};

// TicTacToe.js

import {GAME_MODES /* ... */} from './constants';

const TicTacToe = () => {
  // ...
  const [mode, setMode] = useState(GAME_MODES.medium);

  // ...

  const computerMove = useCallback(() => {
    // Important to pass a copy of the grid here
    const board = new Board(grid.concat());
    const emptyIndices = board.getEmptySquares(grid);
    let index;

    switch (mode) {
      case GAME_MODES.easy:
        index = getRandomInt(0, 8);
        while (!emptyIndices.includes(index)) {
          index = getRandomInt(0, 8);
        }
        break;
      case GAME_MODES.medium:
        // Medium level is basically ~half of the moves are minimax and the other ~half random
        const smartMove = !board.isEmpty(grid) && Math.random() < 0.5;
        if (smartMove) {
          index = minimax(board, players.computer)[1];
        } else {
          index = getRandomInt(0, 8);
          while (!emptyIndices.includes(index)) {
            index = getRandomInt(0, 8);
          }
        }
        break;
      case GAME_MODES.difficult:
      default:
        index = board.isEmpty(grid)
          ? getRandomInt(0, 8)
          : minimax(board, players.computer)[1];
    }
    if (!grid[index]) {
      move(index, players.computer);
      setNextMove(players.human);
    }
  }, [move, grid, players, mode]);

  const changeMode = e => {
    setMode(e.target.value);
  };

  switch (gameState) {
    case GAME_STATES.notStarted:
    default:
      return (
        <Screen>
          <Inner>
            <ChooseText>Select difficulty</ChooseText>
            <select onChange={changeMode} value={mode}>
              {Object.keys(GAME_MODES).map(key => {
                const gameMode = GAME_MODES[key];
                return (
                  <option key={gameMode} value={gameMode}>
                    {key}
                  </option>
                );
              })}
            </select>
          </Inner>
          <Inner>
            <ChooseText>Choose your player</ChooseText>
            <ButtonRow>
              <button onClick={() => choosePlayer(PLAYER_X)}>X</button>
              <p>or</p>
              <button onClick={() => choosePlayer(PLAYER_O)}>O</button>
            </ButtonRow>
          </Inner>
        </Screen>
      );
    case GAME_STATES.inProgress:
    // ...
}

Now we are greeted by the updated start screen, with medium game difficulty selected as default. 

 

Wrapping up 

In this optional section we're gonna add a few finishing touches for the ultimate Tic Tact Toe experience: show game result modal, tweak grid's border styling and add strike through styling for the winning combination. The first task is the easiest one, so let's start with that.

// ResultModal.js

import React from "react";
import styled from "styled-components";
import Modal from "react-modal";

const customStyles = {
  overlay: {
    backgroundColor: "rgba(0,0,0, 0.6)"
  }
};

export const ResultModal = ({ isOpen, close, startNewGame, winner }) => {
  return (
    <StyledModal isOpen={isOpen} onRequestClose={close} style={customStyles}>
      <ModalWrapper>
        <ModalTitle>Game over</ModalTitle>
        <ModalContent>{winner}</ModalContent>

        <ModalFooter>
          <Button onClick={close}>Close</Button>
          <Button onClick={startNewGame}>Start over</Button>
        </ModalFooter>
      </ModalWrapper>
    </StyledModal>
  );
};

const StyledModal = styled(Modal)`
  display: flex;
  flex-direction: column;
  height: 300px;
  position: relative;
  margin: 0 auto;
  top: 10%;
  right: auto;
  bottom: auto;
  width: 320px;  
`;

const ModalWrapper = styled.div`
  display: flex;
  flex-direction: column;
  padding: 24px;
  background-color: #fff;
  max-height: 100%;
  height: 100%;
  align-items: center;
  backface-visibility: hidden;
  padding: 1.25rem;
  border: 1px solid black;
`;

const ModalTitle = styled.p`
  display: flex;
  align-items: center;
  margin-bottom: 20px;
  font-size: 24px;
  font-weight: bold;
  text-transform: uppercase;
`;

const ModalContent = styled.p`
  flex: 1 1 auto;
  text-align: center;
`;

const ModalFooter = styled.div`
  display: flex;
  justify-content: space-between;
  flex: 0 0 auto;
  width: 100%;
`;

const Button = styled.button`
  font-size: 16px;
`;

To customize the modal overlay's styling we'll use customStyles object, per package documentation. Other elements of the modal we will style with styled-components. 

With the styling out of the way, let's import the modal in our main component and show it when the game is over.

// TicTacToe.js

import { ResultModal } from "./ResultModal";

const TicTacToe = () => {
  // ...
  const [modalOpen, setModalOpen] = useState(false);

  // ... 
  
  useEffect(() => {
    const winner = board.getWinner(grid);
    const declareWinner = winner => {
      let winnerStr;
      switch (winner) {
        case PLAYER_X:
          winnerStr = "Player X wins!";
          break;
        case PLAYER_O:
          winnerStr = "Player O wins!";
          break;
        case DRAW:
        default:
          winnerStr = "It's a draw";
      }
      setGameState(GAME_STATES.over);
      setWinner(winnerStr);
      // Slight delay for the modal so there is some time to see the last move
      setTimeout(() => setModalOpen(true), 300);
    };

    if (winner !== null && gameState !== GAME_STATES.over) {
      declareWinner(winner);
    }
  }, [gameState, grid, nextMove]);

  const startNewGame = () => {
    setGameState(GAME_STATES.notStarted);
    setGrid(arr);
    setModalOpen(false); // Close the modal when new game starts
  };

  return gameState === GAME_STATES.notStarted ? (
    <Screen>
      <Inner>
        <ChooseText>Select difficulty</ChooseText>
        <select onChange={changeMode} value={mode}>
          {Object.keys(GAME_MODES).map(key => {
            const gameMode = GAME_MODES[key];
            return (
              <option key={gameMode} value={gameMode}>
                {key}
              </option>
            );
          })}
        </select>
      </Inner>
      <Inner>
        <ChooseText>Choose your player</ChooseText>
        <ButtonRow>
          <button onClick={() => choosePlayer(PLAYER_X)}>X</button>
          <p>or</p>
          <button onClick={() => choosePlayer(PLAYER_O)}>O</button>
        </ButtonRow>
      </Inner>
    </Screen>
  ) : (
    <Container dims={DIMS}>
      {grid.map((value, index) => {
        const isActive = value !== null;

        return (
          <Square
            key={index}
            onClick={() => humanMove(index)}
          >
            {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>}
          </Square>
        );
      })}
     
      <ResultModal
        isOpen={modalOpen}
        winner={winner}
        close={() => setModalOpen(false)}
        startNewGame={startNewGame}
      />
    </Container>
  );

Yep, the modal is there. A new game can be started from here, or the player can close it to see the final board once again (in that case the page has to be reloaded to start a new game). 

Looking at the buttons, you'll notice that they have irregularly shaped border, which goes nicely with the overall styling of the app. Wouldn't it be nice if our grid squares and the result modal had similarly shaped borders? With a bit of experimentation and tweaking, we can come up with a satisfactory styling that will be added to a separate styles.js file.

// styles.js

export const border = `
  border-bottom-left-radius: 15px 255px;
  border-bottom-right-radius: 225px 15px;
  border-top-left-radius: 255px 15px;
  border-top-right-radius: 15px 225px;
  border: 2px solid #41403e;
`;

Here we're simply declaring the CSS styles as a template string, which we can use in our components.

// TicTacToe.js

import { border } from "./styles";

// ...

const Square = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: ${SQUARE_DIMS}px;
  height: ${SQUARE_DIMS}px;
  ${border};                  // Adding new border styles

  &:hover {
    cursor: pointer;
  }
`;


// ResultModal.js

import { border } from "./styles";

// ...
const ModalWrapper = styled.div`
  display: flex;
  flex-direction: column;
  padding: 24px;
  background-color: #fff;
  max-height: 100%;
  height: 100%;
  align-items: center;
  backface-visibility: hidden;
  padding: 1.25rem;
  ${border};                   // Adding new border styles
`;

Note that the syntax for adding reusable styles to a styled component is a variable interpolation inside a template. After these changes the grid looks more consistent with the overall styling.

As the final touch we're gonna add a strike through styling to highlight the winning squares sequence. It was not entirely clear how to best do it at the beginning, however after some research I settled on the way where together with the game's winner the Board class will return the styling for the strike through according to the combination that was the winning one (unless the game was a draw). To get the required styles right took quite a bit of experimentation, but the end result is more than acceptable. 

// Board.js

export default class Board {
  constructor(grid) {
    // ...
    this.winningIndex = null; // track the index of winning combination
  }


 getWinner = (grid = this.grid) => {
    //...
    winningCombos.forEach((el, i) => {
      if (
        grid[el[0]] !== null &&
        grid[el[0]] === grid[el[1]] &&
        grid[el[0]] === grid[el[2]]
      ) {
        res = grid[el[0]];
        this.winningIndex = i;
      } else if (res === null && this.getEmptySquares(grid).length === 0) {
        res = DRAW;
        this.winningIndex = null;
      }
    });
    return res;
  };

 /**
   * Get the styles for strike through based on the combination that won
   */
 getStrikethroughStyles = () => {
    const defaultWidth = 285;
    const diagonalWidth = 400;
    switch (this.winningIndex) {
      case 0:
        return `
          transform: none;
          top: 41px;
          left: 15px;
          width: ${defaultWidth}px;
        `;
      case 1:
        return `
          transform: none;
          top: 140px;
          left: 15px;
          width: ${defaultWidth}px;
        `;
      case 2:
        return `
          transform: none;
          top: 242px;
          left: 15px;
          width: ${defaultWidth}px;
        `;
      case 3:
        return `
          transform: rotate(90deg);
          top: 145px;
          left: -86px;
          width: ${defaultWidth}px;
        `;
      case 4:
        return `
          transform: rotate(90deg);
          top: 145px;
          left: 15px;
          width: ${defaultWidth}px;
        `;
      case 5:
        return `
          transform: rotate(90deg);
          top: 145px;
          left: 115px;
          width: ${defaultWidth}px;
        `;
      case 6:
        return `
          transform: rotate(45deg);
          top: 145px;
          left: -44px;
          width: ${diagonalWidth}px;
        `;
      case 7:
        return `
          transform: rotate(-45deg);
          top: 145px;
          left: -46px;
          width: ${diagonalWidth}px;
        `;
      default:
        return null;
    }
  };

Let's add a Strikethrough element to our main component and see if the styles work.

// TicTactToe.js

 // ...
 
return gameState === GAME_STATES.notStarted ? (

 // ...

  <Strikethrough
    styles={
      gameState === GAME_STATES.over && board.getStrikethroughStyles()
    }
  />
  <ResultModal
    isOpen={modalOpen}
    winner={winner}
    close={() => setModalOpen(false)}
    startNewGame={startNewGame}
  />

  // ...

  const Strikethrough = styled.div`
    position: absolute;
    ${({ styles }) => styles}
    background-color: indianred;
    height: 5px;
    width: ${({ styles }) => !styles && "0px"};
  `;

If board.getStrikethroughStyles() returns styles, we apply them to our element, otherwise it is hidden by having the width of 0px.

Perfect! Now we see a nice strike through whenever the game has a winner. 

And that is a wrap. Fell free to tweak and customise the game to your own liking!

 

Got any questions/comments or other kinds of feedback about this post? Let me know on Twitter.