TypeScript tic-tac-toe

·

6 min read

A typescript tutorial of tic-tac-toe from the react documentation.

Introduction

For the original code written in JavaScript, you can go here. I won't be doing a deep dive into the logic of code, it has been properly documented and well explained here. I will be using replit and since it uses vite, my extensions will be ending with tsx

App.tsx

The Square component receives two props, value and onSquareClick. The value is either null or string and that is because our array is first populated with nulls and then gradually depending on the square that was clicked, the null gets replaced with a string 'X' or 'O'.

The onSquareClick prop is a function that returns nothing, therefore the square component should look like this.

type SquareProps = {
  value: string | null;
  onSquareClick: () => void
}

export default function Square({value, onSquareClick}:SquareProps){
 return <button onClick={onSquareClick} className="square">
            {value}
        </button>
}

In the Board.tsx file where the props passed into the square component came from, in the documentation tutorial, the square component was written out for the whole nine (9) boxes, but we can make things easier by mapping through our squares array.

Also here since our squares array is null and consequentially every time it's updated the array is going to be filled with strings and nulls, we are telling typescript that our square array is a state filled with possibly strings or nulls.

The handleClick function also takes in an index which is a number that is also indicated in the code below.

export default function Board (){
  const [squares, setSquares] = useState<(string | null)[]>(Array(9).fill(null));

  const handleClick = (i:number) => {
    const nextSquares = squares.slice();
    nextSquares[i] = "X";
    setSquares(nextSquares);
  }

  return <div>
    <div className="squares">
      {
        squares.map((square, id) => {
          return <Square  value={square} key={`square-${id + 1}`} onSquareClick={() => handleClick(id)} />
        })
      }
    </div>

  </div>

}

Your updated code should look like this.

Taking turns

Now that clicking on the square displays X, we want to be able to

  • display O on our next click after X

  • ensure that if a square already has a string, it doesn't get overridden.

To ensure the above statements we create a state to handle the turns.

export default function Board() {
    const [squares, setSquares] = useState<(string | null)[]>(Array(9).fill(null));
  const [xIsNext, setXIsNext] = useState(true)

  const handleClick = (i: number) => {
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O"
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext)
  }

  return (
    //...
  );
}

Here we didn't give a type to the xIsNext state because typescript already figured out it's a boolean due to the value passed to the state, as a result setting its state to another data type like number or string via setXisNext will result in an error.

To prevent the squares from being overwritten, we check if the item of that particular index is already filled with a string, if that condition is true, we return out of the function.

function handleClick(i:number) {
  if (squares[i]) {
    return;
  }
  const nextSquares = squares.slice();
  //...
}

At this point, the code should be like this

Declaring winner

Here, we will create a helper function calculateWinner that checks if the game is won and returns "X" ,"O" or null. The helper function takes in an array of 9 squares that are either strings or nulls.

Now we have to indicate the type of array the helper function will take to ensure the safety of our function.

Remember our array of squares is an array of null or strings and this type was already declared in our squares state which is also an array of null or strings. Therefore instead of having to copy and paste types everywhere, let's create a file called types.ts so we can reuse the same types everywhere.

In our types.ts file

export type Squares = (string | null)[];

We can then re-write squares state type in our Board.tsx file to be

import { Squares } from "./types";

export default function Board() {
  const [squares, setSquares] = useState<Squares>(Array(9).fill(null));
  //...
}

Back to our calculateWinner helper function

import { Squares } from "./types";

export function calculateWinner(squares:Squares){
    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 i =0; i < lines.length; i++){
    const [a,b, c] = lines[i];
    if(squares[a] && squares[a] === squares[b] && squares[a] === squares[c]){
      return squares[a]
    }
  }
  return null
}

In the for loop, we are ensuring that an item "X" or "O" exists in the square before we compare it to the other items in every possible winning position. As a result, the function will always return a string | null .

The helper function will be called in our Board component's handleClick function to check if a player has won. This check is performed at the same time we check if a square already has anX or O in it.

function handleClick(i:number) {
  if (squares[i] || calculateWinner(squares)) {
    return;
  }
  const nextSquares = squares.slice();
  //...
}

We can also have a status to let us know if X wins or O. In the code below when we hover on the winner variable, it shows string | null which means TypeScript already figured out the possible return values from our calculateWinner function! therefore, there is no need to write a type for the variable! Hovering on the status will show any and to avoid that we can tell TypeScript that this variable will always be a string.

export default function Board() {
  // ...
  const winner = calculateWinner(squares);
  let status:string;
  if (winner) {
    status = "Winner: " + winner;
  } else {
    status = "Next player: " + (xIsNext ? "X" : "O");
  }

  return (
    <main>
      <div className="status">{status}</div>
      <div className="board-row">
        // ...
       </div>
      </main>
  )
}

The updated code should look this way

Voila! we have a typed tic-tac-toe!

Adding time travel

Again the well-explained logic of this app is here. Instead of writing our creating a Game component as they did in the react documentation, we are just going to use our App component.

import { useState } from "react"

import Board from "./Board"
import { Squares } from "./types"

import './App.css'

export default function App() {
  const [history, setHistory] = useState<Squares[]>([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[currentMove];
  const xIsNext = currentMove % 2 === 0

  const handlePlay = (nextSquares: Squares) => {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1)
  }

  const jumpTo = (nextMove:number) => {
    setCurrentMove(nextMove);
  }

  const moves = history.map((squares, move) => {
    let description:string;
        if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={`move-${move}`}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  })

  return (
    <main >
      <div className="container">
        <Board squares={currentSquares} xIsNext={xIsNext} onPlay={handlePlay} />
        <div>{moves}</div>       
      </div>
    </main>
  )
}

Your code should look like this.

Conclusion

The beauty of TypeScript is its ability to infer types just by the data type a variable holds or by the return type from a function. As a result, it makes it easy to understand what is in code.

I just started learning TypeScript and the whole code can be improved upon. If you have any questions or contributions, please do that in the comment section.

The original code from the react documentation was written by Matt Caroll from the react team.