Real time App with React and WebSocket - Part 2

Learn how to build a simple Real Time Web Application with React and the WebSocket API. Part 2 : Real Time Web Server and Data Flow

Real time App with React and WebSocket - Part 2
Photo by Jon Tyson / Unsplash

In Part 1 we setup the repository and implemented basic Front End using React. At the moment the app isn't so fun because we can't even play the game. In this part we will build the server and then wire the logic all together. By the end, we'll have a playable TicTacToe game!

If you wish to see the full code, you'll find it in this GitHub repository.


Table of Content

Part 1: Building the frontend

  • Technologies Involved
  • Project Scope
  • Implementation
    • Step 1: Create a new Project
    • Step 2: Create the Front End

Part 2: Server & State (we're here!)

  • A Real Time Web Server
    • The HTTP Server
    • The Socket.IO Server
  • State Management with Zustand
    • Creating a Zustand store
    • Using the Store
  • Conclusion

A Real Time Web Server

Now to the heart of this project: the Server. We need two parts: one HTTP server and one Socket.IO Server

The HTTP Server

We will create an HTTP server and request our Socket.IO instance to listen to it. We could create a separate app only for the server but Next.js offers a capability to create our own server on top of theirs, so let's use that.

If we check Next.js's documentation for custom servers, this is what we would need:

import { createServer } from "http";
import { parse } from "url";

import next from "next";

//We will implement this in a minute
//import TicTacToeService from "./TicTacToeServer";

const port = parseInt(process.env.PORT || "3000", 10);
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  const server = createServer((req, res) => {
    const parsedUrl = parse(req.url!, true);

    handle(req, res, parsedUrl);
  }).listen(port);

  //once we have an http server listening to next routes,
  //we can listen to it and bind our Socket.io server!
  //new TicTacToeService(server);

  console.log(
    `> Server listening at http://localhost:${port} as ${
      dev ? "development" : process.env.NODE_ENV
    }`
  );
});
server/index.ts

Before we go further, let's talk about what we just did:

First, we created a Next.js app using const app  = next(/*opts*/);. This app handles all the logic related to Next.js. So we are free to build our own server, using the stack we wish and make sure we forward the remaining requests to the app, thanks to app.getRequestHandler();.

After having the app, we called its prepare method so we could create our server. I used createServer from the http module. But I could have used express or fastify or something else. What's important is that we need an HTTP server.

In our use case, we don't need our server to handle any route but a Socket.IO server. So we forward any requests to the handle method and we are done with it.

We should be able to run yarn dev to start the app, with the logs showing > Server listening at http://localhost:3000 as development

Finally, we can bind our HTTP server to our Socket.IO server!


Note: The reason we did this instead of using the Next.js API routes is because Real time communication doesn't work with them. At some point the server will drop the connection to clean up resources.


The Socket.IO Server

Now that we have our HTTP server, we can create our Socket.IO Server.

Let's take a break and see what Socket.IO means, how it is different from an HTTP server and why we need both.

About what Socket.IO is:

Socket.IO is a library that enables real-time, bidirectional and event-based communication between the browser and the server

In comparison, with regular HTTP servers, a user can make a Request and that Server can only Respond to it. Once the Response is sent, the connection is closed. There's also no way for the server to send information to a user if that user didn't make a Request first.

With Real Time Communication (RTC), we can keep a long lived connection and send messages from both sides at any time.

It is most useful when multiple users are connected to the same server, like a chat application. When a user writes a new message, all users should be notified. For the sake of our game, using a WebSocket Server would also work.

Socket.IO in the end is a library that implements the WebRTC technologies we need. The main technology involved in WebRTC is WebSocket. WebSocket is a communication protocol (like HTTP) that allows bi directional communication.

We could have used a number of different libraries or even used WebSocket by hand, but that's too much of a hassle and I do like socket.IO. The library is easy to use.

Then, do we need both? A Socket.IO server can run on its own but we still need an HTTP server to make our React App work. It would force us to create a repository and deploy it just for that purpose. Instead, because of the similarities between WebSocket and HTTP, we can make them share the same port quite easily and that's just what we do!

Now I hope you have a tiny better understanding of what it is so let's see how to use it.

For this part, I'll implement the server in a slightly different way than I did in the repository. Because in the repository, I have a little bit more code for type-checking and utility functions. In the article I prefer to focus on the core of the subject.

import io from "socket.io";

export default class TictacToeService {
  /*We construct a new service that connects to an httpServer*/
  constructor(httpServer) {
    this.server = io(httpServer);

    this.games = {};

    //now that we have a websocket server,
    //we can listen to new connections!
    this.server.on("connection", (client) => {
      client
        .on("disconnect", () => {
          delete this.games[client.id];
        })
        .on("start-game", () => this.startGame(client))
        .on("play", (boxId) => this.playerPlay(client, boxId));
    });
  }

  startGame(client) {
    console.log("Client wants to start a new Game");
  }

  playerPlay(client, boxId) {
    console.log("Client wants to play");
  }
}
server/TicTacToeServer.ts

Let's pause here already. We just created a new class that when constructed, will start a Socket.IO server, listen to the httpServer's port and then listen to the following events: start-game and play.

Those are the two events that the user can send to the server. Everything else will be ignored. The disconnect event is a bit special as it's called when the connection is closed.

If we were to compare this to an HTTP server, it would look something like:

app.get("/start-game", (req, res) => {
    console.log("user wants to start a game");
});

app.get("/play", (req, res) => {
    console.log("user wants to play");
});
some code snippet

The main difference is that the client parameter is not dropped until the connection is explicitly closed. This allows us to do the next step: replying back to the client!

  /*
import {
  BoxValue,
  GameState,
  Players,
} from "../src/core/TicTacToeAPI";
*/

  /*
  We create a new game for that user. So multiple users can play their own games individually!
  */
  startGame(client) {
    this.games[client.id] = this.createGame();
    const game = this.games[client.id];

    //And this when the server replies to the client!
    client.emit("state", game);
  }

  /*
  We check if it's the player's turn and then update the game
  */
  playerPlay(client, boxId) {
    const game = this.games[client.id];

    if (
      game?.playerTurn === Players.PLAYER &&
      game?.grid[boxId] === BoxValue.EMPTY
    ) {
      game.grid[boxId] = BoxValue.PLAYER;

      if (!this.checkVictory(client, game)) {
        game.playerTurn = Players.BOT;
        client.emit("state", game);

        this.botPlay(client, game);
      }
    }
  }

  //remaining omitted methods can be found here:
  // > https://github.com/ArmandDu/tic-tac-toe-websockets/blob/master/server/TicTacToeServer.ts

  //botPlay(client, game) {...}
  //checkVictory(client, game) {...}
server/TicTacToeServer.ts

With this last piece of code, we have what we need to be able to play a TicTacToe game against a bot in real time! In order to test it, we will connect to it in the React app using socket.io-client library. But first we need to decide on how we will manage our React state.

State Management with Zustand

Now that we have a nice server that can handle all the logic for our game, we can connect our React App to it!

React is a library to build UI. Even if it does provide a way to internally handle state changes, the business logic and data fetching is left up to the developer to choose their tools and implementations. For this project, we have to answer the following question: how do we automatically react to both user and server events in our React Application?

There are many answers to this question. The most simple one would be to handle all this logic inside the main component's state itself. The downside of this solution is that we would have poor separation of concerns as our component would be in charge of dealing with the global state changes, handling user requests and managing the server updates. Another way would be to use Redux and have our events wrapped inside thunks, actions creators & co.

I wanted to settle for something that would let me hide the implementation details behind a nice API without having to rely on a complex Redux configuration. A while ago I stumbled upon Zustand. It's small, easy to understand yet the most powerful one I found so far.

Creating a Zustand store

Let's see how Zustand works:

import create from "zustand";
import { combine } from "zustand/middleware";

import ioClient from "socket.io-client";

/*
 The initial state shapes what values we can have in our store.
 We can order them as we like or use multiple stores.
 For our game, I'll use only one store.

 Our server only sends the game state updates so that's almost all we need.
 We use the 'ready' state to know if we are connected to the server or not.
*/
const initialState = {
  game: null,
  ready: false,
};

/*
 Here we have access to two functions that
 let us mutate or get data from our state.

 This is where the magic happens, we can fully hide
 the WebSocket implementation here and then use our store anywhere in our app!
 */
const mutations = (setState, getState) => {
  const socket = ioClient();

  // this is enough to connect all our server events
  // to our state managment system!
  socket
    .on("connect", () => {
      setState({ ready: true });
    })
    .on("disconnect", () => {
      setState({ ready: false });
    })
    .on("state", (gameState) => {
      setState({ game: gameState });
    });

  return {
    actions: {
      startGame() {
        socket.emit("start-game");
      },

      play(boxId: number) {
        const isPlayerTurn = getState().game?.playerTurn === Players.PLAYER;

        if (isPlayerTurn) {
          socket.emit("play", boxId);
        }
      },
    },
  };
};

//We created our first store!
export const useStore = create(combine(initialState, mutations));
src/store.ts

Our state management for this app holds in a few lines. But it already shows that we can configure it as we like and connect it to other data sources quite easily. Now let's see how to use it!


Note: In the repository, I added a few helper tools to help the Type checker. You'll see a few more line of codes where I import the interfaces for the different entities and have a wrapper around the socket.io-client object.  I omitted those in this article to keep things concise.


Using the Store

We already have a hint on how the Zustand store works: it's a React hook! We can spot it because we named our component useStore rather than store.

Let's go back to our Game component (src/components/Game.tsx) and remove all the mock data and use our store!

import React from "react";
import { Box } from "./Box";
import { AlertModal } from "./AlertModal";

enum BoxValue {
  EMPTY,
  BOT,
  PLAYER
}

enum Players {
  BOT,
  PLAYER
}

const playerLabel = {
  [Players.BOT]: <i className={"far fa-circle"} />,
  [Players.PLAYER]: <i className={"fas fa-times"} />
};

export function Game() {
  const game = useStore((store) => store.game);
  const isOnline = useStore((store) => store.ready);
  const actions = useStore((store) => store.actions);
  const turnIcon = game ? playerLabel[game.playerTurn] : null;

  return (
    <div className="tic-tac-toe">
      <h1>Tic Tac Toe</h1>
      
      {/*Remember this component from part 1? Now we can use it!
      
      1/ show a modal if client is not connected to the socket
      2/ show "It's a Draw" if the game ended with a draw
      3/ show the Winner if the game ended with a winner*/}
      <AlertModal open={!isOnline}>
        <h2>Not Connected</h2>
        <sub>Please wait</sub>
      </AlertModal>
      
      <AlertModal open={game !== null && game.draw}>
        <h2>It&apos;s a Tie</h2>
        <button onClick={actions.startGame}>Play Again</button>
      </AlertModal>

      <AlertModal open={game !== null && game.winner !== null}>
        {game?.winner === Players.PLAYER && <h2>You win!</h2>}
        {game?.winner === Players.BOT && <h2>You loose!</h2>}
        <button onClick={actions.startGame}>Play Again</button>
      </AlertModal>

      <div className="game">
          {/*... unchanged*/}
    </div>
  );
}
src/components/Game.tsx

And that's it! We use our store similarly as if it was a React.useMemo, we get a callback function and return the values we want. It's best to return only the data we need because Zustand will smartly update the component if they change. If we were to return the entire store const everything = useStore(store => store) then the component would re-render any time any field of the store would change.

With that, if your server is properly working, you should be able to play the game! Enjoy.

Conclusion

This is a very small application but it does feature everything needed to setup a real time app. Next you could attempt to improve the AI and make it smarter (or even unbeatable with a min max algorithm). Or make it playable with two remote players. Or completely create a new game or app, the skeleton should be enough to build something else.

Again, we focused on the minimal requirements here. Looking at this an HTTP server would have been enough. A more concrete example for WebSocket use case is when you want to build a chat system: many users connect to the same server and when a new message is sent, the server forwards them to all the users.

There's plenty of different technologies to achieve the same goal, here the objective was to focus on Real Time Communication implementation with the minimum amount of code (even though we spent a lot of time on things like setting up yarn 2 and the next app).

Hope you enjoyed this series of articles!