Build the Game of Life with React and TypeScript

In this tutorial, we'll develop the popular Game of Life using React and TypeScript. The Game of Life was created by the late John Conway in 1970. It consists of a grid of cells, each either alive or dead which interacts with its neighbours following a set of rules. It is more of a simulation or cellular automation than a game as it requires no input from the user.

I find it to be a good project for practicing some useful concepts in React. It is relatively easy to build, and only took about 150 lines of code to complete. This project was originally recorded by Ben Awad in this video, however, this tutorial makes several modifications to the codebase.

Prerequisites

This tutorial assumes a basic knowledge of React (including Hooks) and TypeScript.

Getting started

Let's set up our React project with TypeScript by running the following command in the terminal:

npx create-react-app game-of-life --template typescript

You can also grab the starter files for the project here and follow the instructions in the README if you prefer.

Create the grid

A grid is naturally made up of a number of rows and columns. Let's start by creating variables in App.tsx to keep track of these values, including the grid itself. Store the grid in state so that it can be easily updated. For this we will employ the useState hook. The useState hook returns a stateful value, and a function to update it. Destructure those return values into grid and setGrid variables as shown below.

// App.tsx
import { FC, useState } from "react";

const numRows = 25;
const numCols = 35;

const App: FC = () => {
  const [grid, setGrid] = useState();
};

Note that we're annotating the type of our component as a Functional Component(FC). This makes sure that the signature of our function is correct and it returns valid JSX. Also, all the code in this tutorial will be written in one file, namely App.tsx.

Next, we want to initialise the grid. useState accepts one argument which will be returned as the initial state on the first render. Create a function that returns an array of random live and dead cells.

// App.tsx
const randomTiles: = () => {
  const rows = [];
  for (let i = 0; i < numRows; i++) {
    rows.push(Array.from(Array(numCols), () => (Math.random() > 0.7 ? 1 : 0))); // returns a live cell 70% of the time
  }
  return rows;
}

const App = () => {
  const [grid, setGrid] = useState(() => {
    return randomTiles();
  });
};

The randomTiles function creates a multidimensional array of randomly placed 0s and 1s. 0 means dead and 1 means alive. The length of the array is the number of rows we declared earlier and each array in it contains numCols items (in this case, 35). Notice that the type is annotated as an array of zeroes and ones. You can already see below what our grid will look like:
Multidimensional array shown in the console

Now, whenever the App component is rendered for the first time, the initial state will be a grid of random cells. Next thing is to display them. Update your App.tsx file as shown below:

// App.tsx
const App = () => {
  const [grid, setGrid] = useState(() => {
    return randomTiles();
  });

  return (
    <div>
      {grid.map((rows, i) =>
        rows.map((col, k) => (
          <div
            style={{
              width: 20,
              height: 20,
              backgroundColor: grid[i][k] ? "#F68E5F" : undefined,
              border: "1px solid #595959",
            }}
          />
        ))
      )}
    </div>
  );
};

The code above iterates over the grid, which has been initialised to randomTiles, and each time generates a 20 x 20 box to represent a cell. The background color of each cell is dependent on whether it is alive or dead.

At the moment, the cells formed are in a straight line as shown above. We need them to be arranged neatly into a grid. To achieve that, let us make the wrapping div a Grid container and style it as follows:

// App.tsx
<div
  style={{
    display: "grid",
    gridTemplateColumns: `repeat(${numCols}, 20px)`,
    width: "fit-content",
    margin: "0 auto",
  }}
>{...}</div>
//I use ... to denote code already established.

Now that we have what we're looking for, you can style the page in any other way you want.

Handle cell clicks

Apart from the randomly generated cell state, we want each cell to be clickable to make it either alive or dead. Add an event handler to the cell div as follows:

// App.tsx
return (
  <div
    style={
      {
        // ...
      }
    }
  >
    {grid.map((rows, i) =>
      rows.map((col, k) => (
        <div
          key={`${i}-${k}`}
          onClick={() => {
            let newGrid = JSON.parse(JSON.stringify(grid));
            newGrid[i][k] = grid[i][k] ? 0 : 1;
            setGrid(newGrid);
          }}
          style={
            {
              // ...
            }
          }
        ></div>
      ))
    )}
  </div>
);

What the click event handler above does is:

  • It clones the grid array into a newGrid,
  • Finds the clicked cell by it's index and checks if it's alive or dead,
  • If the cell is currently alive, it makes it dead and vice versa,
  • Finally, it updates the state with the modified newGrid.

It is best practice to always add a unique identity to elements in React to help React know when it has changed. Set the key attribute of each cell to its specific position in the grid.

Run the simulation

There's no game of life without the actual interaction between the cells, so let's work on that. Let's start by storing the running status of the simulation in state, same way we did the grid, then initialize it to false. Let's allow TypeScript infer the type for us here which will be boolean.

// App.tsx
const App = () => {
  const [grid, setGrid] = useState(() => {
    return randomTiles();
  });

  const [running, setRunning] = useState(false);

  // ...
};

By default, the simulation is not running. Now, let's create a button to start or stop the simulation:

// App.tsx
<button
  onClick={() => {
    setRunning(!running);
  }}
>
  {running ? "Stop" : "Start"}
</button>

Next up, we'll work on implementing the interactions between the cells and their neighbors following the rules of the game which include:

  • Any live cell with fewer than two live neighbours dies, as if by underpopulation.
  • Any live cell with two or three live neighbours lives on to the next generation.
  • Any live cell with more than three live neighbours dies, as if by overpopulation.
  • Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction.

Create a positions array outside the App component. This array represents the eight neighbors surrounding a cell, which we will make use of within the simulation.

// App.tsx
import { useState, useCallback } from "react";

const positions = [
  [0, 1],
  [0, -1],
  [1, -1],
  [-1, 1],
  [1, 1],
  [-1, -1],
  [1, 0],
  [-1, 0],
];

Within the App component, create a function called runSimulation using the useCallback hook and pass the grid as an argument. The reason why useCallback is being used here is to prevent our function from being created every time the App component is rendered. useCallback creates a memoized function every time it's dependency array changes, this means that the function will be created only once and then run when necessary. In this case, we'll leave the dependency array empty.

// App.tsx
const App = () => {
  // ...
  const runningRef = useRef(running);
  runningRef.current = running;

  const runSimulation = useCallback((grid) => {
    if (!runningRef.current) {
      return;
    }

    let gridCopy = JSON.parse(JSON.stringify(grid));
    for (let i = 0; i < numRows; i++) {
      for (let j = 0; j < numCols; j++) {
        let neighbors = 0;

        positions.forEach(([x, y]) => {
          const newI = i + x;
          const newJ = j + y;

          if (newI >= 0 && newI < numRows && newJ >= 0 && newJ < numCols) {
            neighbors += grid[newI][newJ];
          }
        });

        if (neighbors < 2 || neighbors > 3) {
          gridCopy[i][j] = 0;
        } else if (grid[i][j] === 0 && neighbors === 3) {
          gridCopy[i][j] = 1;
        }
      }
    }

    setGrid(gridCopy);
  }, []);

  // ...
};

We're creating runSimulation once but we want the current running value at all times, and the function will not keep updating the value for us. To fix that, let's create a runningRef variable using the useRef hook and initialize it to the current value of the running state. This way, the running status is always up to date within our simulation because it is being stored in a ref. Whenever the .current property of runningRef is false, the function will stop, otherwise it will proceed to work with the rules of the game.

Now, runSimulation clones the grid, loops over every cell in it and computes the live neighbors that each cell has by iterating over the positions array. It then checks to make sure that we're not going out of bounds and are within the rows and columns in the grid. If that condition is met, it increments the number of live neighbors of the cell in question. The forEach loop will run 8 times for each cell.

Next, it enforces the rules. If the number of live neighbors of the cell is less than 2 or greater than 3, the cell dies. Else, if the cell is dead and it has exactly 3 neighbors, the cell lives and proceeds to the next generation. After all the cells are covered, it updates the grid state with the gridCopy.

A very useful custom hook

To make the simulation continuous, we need a function that runs it after a specified interval. Let's fire the setInterval method when the Start button is clicked:

// App.tsx
<button
  onClick={() => {
    setRunning(!running);
    if (!running) {
      runningRef.current = true;
    }
    setInterval(() => {
      runSimulation(grid);
    }, 1000);
  }}
>
  {running ? "Stop" : "Start"}
</button>

The click event handler updates the running state to its opposite, but in case it is false, it changes the ref to true and calls runSimulation every second. If you run this in your browser, you'll see that the simulation isn't running as it should. It appears to be stuck in a loop between two or three generations. This is due to the mismatch between the React programming model and setInterval which you can read more about here.

While researching a solution to this issue, I discovered this custom hook written by Dan Abramov called useInterval. Create a file called useInterval.tsx in your project directory and paste the following code into it:

// useInterval.tsx
import { useEffect, useRef } from "react";

function useInterval(callback: () => void, delay: number | null) {
  const savedCallback = useRef(callback);

  // Remember the latest callback if it changes.
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // Set up the interval.
  useEffect(() => {
    // Don't schedule if no delay is specified.
    if (delay === null) {
      return;
    }

    const id = setInterval(() => savedCallback.current(), delay);

    return () => clearInterval(id);
  }, [delay]);
}

export default useInterval;

Import the hook into the App component and make use of it as follows:

// App.tsx
import useInterval from "./useInterval";

// Put this right under runSimulation() inside the App function
useInterval(() => {
  runSimulation(grid);
}, 150);

The syntax of this hook looks identical to setInterval, but works a bit differently. It’s more like setInterval and clearInterval tied in one, and it's arguments are dynamic. Delete the setInterval function from the click handler and watch our app run smoothly.

Clear the grid

Let's add a function to empty the grid of all live cells. Create a function called generateEmptyGrid:

// App.tsx
const generateEmptyGrid = (): number[][] => {
  const rows = [];
  for (let i = 0; i < numRows; i++) {
    rows.push(Array.from(Array(numCols), () => 0));
  }
  return rows;
};

This function looks like randomTiles except it returns a multidimensional array containing only zeroes. Create a button to update the state with the new array of dead cells:

// App.tsx
<button
  onClick={() => {
    setGrid(generateEmptyGrid());
  }}
>
  Clear board
</button>

When you check the browser, you should see an error that looks like this:

This is because of the way TypeScript works. When you initialize a variable, TypeScript infers its type as narrowly as possible if you don't explicitly annotate it. In our case, when we declared the grid state, we initialized it to randomTiles. Because we didn't annotate the type of randomTiles, it's type was inferred as () => (0 | 1)[][], that is, a function that returns only zeroes and ones.

Now, generateEmptyGrid's type is inferred as () => number[][] which is not assignable to () => (0 | 1)[][]. That is the reason behind that error above which shows that our code failed to compile. For our app to work, the types have to be compatible. Let's annotate their types so that they are the same:

// App.tsx
const generateEmptyGrid = (): number[][] => {
  const rows = [];
  for (let i = 0; i < numRows; i++) {
    rows.push(Array.from(Array(numCols), () => 0));
  }
  return rows;
};

const randomTiles = (): number[][] => {
  const rows = [];
  for (let i = 0; i < numRows; i++) {
    rows.push(Array.from(Array(numCols), () => (Math.random() > 0.7 ? 1 : 0)));
  }
  return rows;
};

Now that they are both multidimensional arrays containing numbers and can be assigned to each other, our Clear button should work as expected. Let's add in another button to randomize the tiles again if the user desires to.

// App.tsx
<button
  onClick={() => {
    setGrid(randomTiles());
  }}
>
  Random
</button>

This click handler just updates the state with our previously declared randomTiles function that returns randomly placed 0s and 1s.

Conclusion

In this tutorial, we have successfully built Conway's Game of Life using React and TypeScript. We covered how to make use of some React hooks including useState, useCallback and useRef. We saw how React and setInterval don't work too well together and fixed the issue with a custom hook. We also discussed how TypeScript infers types when they are not annotated, how a type mismatch caused our code not to compile and how to solve the problem.

The complete code for this project can be found in this GitHub repository. I hope you have gained some value from this article. Your feedback will be appreciated in the comments.

Thanks for reading!

19