Build the Game of Life with React and TypeScript
Tags: react, typescript, webdev
Published: 2021-07-07 11:19:42

In this tutorial, we'll develop the popular [Game of Life](https://en.wikipedia.org/wiki/Conway%27s_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](https://www.youtube.com/watch?v=DvVt11mPuM0), however, this tutorial makes several modifications to the codebase.

## Prerequisites

This tutorial assumes a basic knowledge of React (including [Hooks](https://reactjs.org/docs/hooks-intro.html)) and [TypeScript](https://www.typescriptlang.org/).

## 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](https://github.com/toluagboola/game-of-life-starter-files) 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`](https://reactjs.org/docs/hooks-reference.html#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.

```typescript
// 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.

```typescript
// 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q7w8j6195x5t1sp8cznn.png)

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:

```tsx
// 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.

![Cells arranged in a straight line](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i8kqmrmp92xx0nskzw1x.png)

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:

```tsx
// 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.

![Grid of cells](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zojk3qthpfvypzcdlm7l.png)

## 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:

```tsx
// 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`.

```typescript
// 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:

```tsx
// 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.

```typescript
// 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`](https://reactjs.org/docs/hooks-reference.html#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.

```typescript
// 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`](https://reactjs.org/docs/hooks-reference.html#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:

```tsx
// 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](https://overreacted.io/making-setinterval-declarative-with-react-hooks/).  

![Interval error](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4piie6v6vclyefu1h2yd.gif)

While researching a solution to this issue, I discovered this [custom hook](https://usehooks-typescript.com/react-hook/use-interval) written by Dan Abramov called `useInterval`. Create a file called `useInterval.tsx` in your project directory and paste the following code into it:

```tsx
// 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:

```typescript
// 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`:

```tsx
// 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:

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

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

![Type error](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6y99j2rggpo3tmmx6gpx.png)

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:

```typescript
// 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.

```tsx
// 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](https://github.com/toluagboola/react-game-of-life). I hope you have gained some value from this article. Your feedback will be appreciated in the comments.

Thanks for reading!