Search code examples
reactjsmultidimensional-arrayreact-hooksstrict-mode

Using React Strict mode with random numbers


I'm trying to create a game of Tic-Tac-Toe with an AI using react. I have a function for my AI that retuns a random row and column to place a piece in. However, when using strict mode the AI takes two turns because the random number gets generated again. From the reading that I've done this indicates that I'm updating my board state incorrectly but I'm not sure where I've gone wrong. Below is my code for placing the mark on the board.

const handlePlacePiece = (row: number, col: number) => {
    let currentPiece = board[row][col];
    if (currentPiece === "-") {
        const newBoard = Array.from(board);
        newBoard[row][col] = currentPlayer; // The current players mark either 'X' or 'O'
        setBoard(newBoard);
        setCurrentPlayer(currentPlayer === "X" ? "O" : "X");
    }
};

And here is my initial board state:

const [board, setBoard] = useState([
    ["-", "-", "-"],
    ["-", "-", "-"],
    ["-", "-", "-"],
]);

Here is my ai function:

export function easyAi(board: any) {
    let col = getRandomMove(); //Math.floor(Math.random() * 3);
    let row = getRandomMove();
    while (board[row][col] !== "-") {
        col = getRandomMove();
        row = getRandomMove(); 
    }
    return { row, col };
}

Calling handlePlacePiece (this is also an onClick but this produces the correct outcome):

if (gameType === "AI") {
        if (currentPlayer === aiPiece) {
            const { row, col } = easyAi(board);
            handlePlacePiece(row, col);
        }
    }

Full file on GitHub: https://github.com/lukeypap/Tic-Tac-Toe-React/blob/master/src/components/Board/index.tsx

Please let me know if you need extra details, Thank you.


Solution

  • Issues

    1. The logic for the "AI" to take its turn is right in the function body as an unintentional side-effect. This means anytime the component is rendered for any reason at all the code it invoked. The React.StrictMode runs certain functions twice as a way to help you detect unexpected side-effects.

      if (gameType === "AI" && !gameOver) {
        if (checkDraw(board) || checkWinner(board)) {
          console.log("setting game over true");
          setGameOver(true);
        } else if (currentPlayer === aiPiece && !gameOver) {
          const { row, col } = easyAi(board);
          handlePlacePiece(row, col);
        }
      }
      

      React.StricMode

      Strict mode can’t automatically detect side effects for you, but it can help you spot them by making them a little more deterministic. This is done by intentionally double-invoking the following functions:

      • Class component constructor, render, and shouldComponentUpdate methods
      • Class component static getDerivedStateFromProps method
      • Function component bodies <--
      • State updater functions (the first argument to setState)
      • Functions passed to useState, useMemo, or useReducer
    2. The handlePlacePiece callback is mutating the board state object. All state, and nested state, that is being updated should be shallow copied in order to create new references. The callback only creates a shallow copy of the outer board array, but all row arrays are still references to the previous state.

      const newBoard = Array.from(board);
      newBoard[row][col] = currentPlayer; // <-- mutation!
      setBoard([...newBoard]);
      

    Solution

    Use a functional state update to update the board state from the previous value, using Array.prototype.map to shallow copy the rows and columns that are being updated.

    Move the "AI" turn logic into a useEffect hook. I suggest moving also the game check into a useEffect hook to be called when the board state updates.

    Suggestions:

    enum PIECE {
      X = "X",
      O = "O",
      EMPTY = "-",
    }
    
    interface props {
      gameType: "AI" | "HUMAN";
      selectedPiece: PIECE;
      startingPlayer: PIECE;
    }
    
    const index = ({ gameType, selectedPiece, startingPlayer }: props) => {
      const [board, setBoard] = useState(
        Array(3).fill(Array(3).fill(PIECE.EMPTY))
      );
    
      const [currentPlayer, setCurrentPlayer] = useState(startingPlayer);
      const [aiPiece, setAiPiece] = useState(
        selectedPiece === PIECE.O ? PIECE.X : PIECE.O
      );
      const [gameOver, setGameOver] = useState(false);
      const [checkingMove, setCheckingMove] = useState(false);
    
      const handlePlacePiece = (row: number, col: number) => {
        let mark = board[row][col];
        if (mark === PIECE.EMPTY) {
          setCheckingMove(true);
          setBoard((board) =>
            board.map((boardRow: PIECE[], i: number) =>
              i === row
                ? boardRow.map((boardCol: PIECE, j: number) =>
                    j === col ? currentPlayer : boardCol
                  )
                : boardRow
            )
          );
        }
      };
    
      useEffect(() => {
        const isWinner = checkWinner(board); // *
        const isDraw = checkDraw(board);
    
        if (isWinner || isDraw) {
          if (isWinner) {
            console.log(`Winner is: ${isWinner}`);
          } else if (isDraw) {
            console.log("Draw");
          }
          setGameOver(true);
        } else {
          setCurrentPlayer((current) => (current === PIECE.X ? PIECE.O : PIECE.X));
          setCheckingMove(false);
        }
      }, [board]);
    
      useEffect(() => {
        if (
          !checkingMove &&
          gameType === "AI" &&
          !gameOver &&
          currentPlayer === aiPiece
        ) {
          const { row, col } = easyAi(board);
          handlePlacePiece(row, col);
        }
      }, [checkingMove, currentPlayer, aiPiece, gameType, gameOver, board]);
    
      return (
        ...
      );
    };
    

    * Note: The checkWinner utility was updated to return the winning piece.

    export const checkWinner = (board: string[][]) => {
      //Rows
      if (
        board[0][0] == board[0][1] &&
        board[0][1] == board[0][2] &&
        board[0][0] != "-"
      ) {
        return board[0][0];
      }
      if (
        board[1][0] == board[1][1] &&
        board[1][1] == board[1][2] &&
        board[1][0] != "-"
      ) {
        return board[1][0];
      }
      if (
        board[2][0] == board[2][1] &&
        board[2][1] == board[2][2] &&
        board[2][0] != "-"
      ) {
        return board[2][0];
      }
      //Cols
      if (
        board[0][0] == board[1][0] &&
        board[1][0] == board[2][0] &&
        board[0][0] != "-"
      ) {
        return board[0][0];
      }
      if (
        board[0][1] == board[1][1] &&
        board[1][1] == board[2][1] &&
        board[0][1] != "-"
      ) {
        return board[0][1];
      }
      if (
        board[0][2] == board[1][2] &&
        board[1][2] == board[2][2] &&
        board[0][2] != "-"
      ) {
        return board[0][2];
      }
      //Diags
      if (
        board[0][0] == board[1][1] &&
        board[1][1] == board[2][2] &&
        board[0][0] != "-"
      ) {
        return board[0][0];
      }
      if (
        board[0][2] == board[1][1] &&
        board[1][1] == board[2][0] &&
        board[0][2] != "-"
      ) {
        return board[0][2];
      }
    };