tutorial // Jun 10, 2021

How to Set Up a Websocket Client with JavaScript

How to create a reusable function that establishes a websocket client that connects to an existing websocket server to send and receive messages.

How to Set Up a Websocket Client with JavaScript

Getting Started

If you haven't already—and you don't have your own, existing websocket server to connect to—it's recommended that you complete our companion tutorial on How to Set Up a Websocket Server with Node.js and Express.

If you've already completed that tutorial, or, have a websocket server that you'd like to test with, for this tutorial, we're going to use the CheatCode Next.js Boilerplate as a starting point for wiring up our websocket client:

Terminal

git clone https://github.com/cheatcode/nextjs-boilerplate.git

After you've cloned a copy of the project, cd into it and install its dependencies:

Terminal

cd nextjs-boilerplate && npm install

Next, we need to install one additional dependency, query-string, which we'll use to parse query params from our URL to pass along with our websocket connection:

Terminal

npm i query-string

Finally, start the development server:

Terminal

npm run dev

With that, we're ready to get started.

Building the websocket client

Fortunately for us, modern browsers now natively support websockets. This means that we don't need to depend on any special libraries on the client to set up our connection.

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  // We'll write our code here...
};

export default websocketClient;

Here, we begin to spec out our websocket client. First, notice that we're creating a function called websocketClient that we intend to import elsewhere in our code. The idea here is that, depending on our app, we may have multiple points of use for websockets; this pattern affords us the ability to do that without having to copy/paste a lot of code.

Looking at the function, we're setting it up to take in two arguments: options, an object containing some basic settings for the websocket client and onConnect, a callback function that we can call after we've established a connection with the server (important if you're building a UI that wants/needs the websocket connection established before you load your full UI).

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  let url = settings?.websockets?.url;
  let client = new WebSocket(url);

  client.addEventListener("open", () => {
    console.log(`[websockets] Connected to ${settings?.websockets?.url}`);
  });

  client.addEventListener("close", () => {
    console.log(`[websockets] Disconnected from ${settings?.websockets?.url}`);
    client = null;
  });
};

export default websocketClient;

Building out the body of our function, we need to set up our client connection to the websocket server. To do it, here, we've imported the /settings/index.js file at the root of the boilerplate we cloned at the start of the tutorial. This file contains a function which pulls configuration data for our front-end from an environment-specific file located in the same folder at /settings from the root of the project.

If you look in that folder, two example files are provided settings-development.json and settings-production.json. The former is designed to contain the development environment settings while the latter is designed to contain the production environment settings. This distinction is important because you only want to use test keys and URLs in your development environment to avoid breaking a production environment.

/settings/settings-development.json

const settings = {
  [...]
  websockets: {
    url: "ws://localhost:5001/websockets",
  },
};

export default settings;

If we open up the /settings/settings-development.json file, we're going to add a new property to the settings object that's exported from the file called websockets. We'll set this property equal to another object, with a single url property set to the URL of our websocket server. Here, we're using the URL we expect to exist from the other CheatCode tutorial on setting up a websocket server that we linked to at the start of this tutorial.

If you're using your own existing websocket server, you will set that here instead. Of note, when we're connecting to a websocket server, we prefix our URL with ws:// instead of http:// (in production, we'd use wss:// for a secure connection just like we use https://). This is because websockets are an independent protocol from the HTTP protocol. If we prefixed this with http://, our connection would fail with an error from the browser.

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  let url = settings?.websockets?.url;
  let client = new WebSocket(url);

  client.addEventListener("open", () => {
    console.log(`[websockets] Connected to ${settings?.websockets?.url}`);
  });

  client.addEventListener("close", () => {
    console.log(`[websockets] Disconnected from ${settings?.websockets?.url}`);
    client = null;

    if (options?.onDisconnect) {
      options.onDisconnect();
    }
  });
};

export default websocketClient;

Back in our client code, now, we pull in our websockets URL from the settings file, storing it in a variable url declared using let (we'll see why later). Next, to establish our connection to that URL, in another variable just below it client (also using let), we call to new WebSocket() passing in the url for our server. Here, WebSocket() is a native browser API.

CD6Jlj6F7E1vCyAO/43d2Oa1EkbSFBRBF.0
Accessing the native WebSocket constructor in the browser console.

You don't see an import for it here because, technically speaking, when our code loads up in the browser, the global window context already has WebSocket defined as a variable.

Next, below our client connection, we add a pair of JavaScript event listeners for two events that we anticipate our client to emit: open and close. These should be self-explanatory. The first is a callback that fires when our websocket server connection opens, while the second fires whenever our websocket server connection closes.

Though not necessary in a technical sense, these are important to have for communicating back to yourself (and other developers) that a connect was successful, or, that a connection was lost. The latter scenario occurs when a websocket server becomes unreachable or intentionally closes the connection to the client. Typically, this happens when a server restarts, or, internal code kicks a specific client out (the "why" for that kick is app-dependent and nothing built into the websockets spec).

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  let url = settings?.websockets?.url;

  if (options.queryParams) {
    url = `${url}?${queryString.stringify(options.queryParams)}`;
  }

  let client = new WebSocket(url);

  client.addEventListener("open", () => {[...]});

  client.addEventListener("close", () => {[...]});

  const connection = {
    client,
    send: (message = {}) => {
      if (options.queryParams) {
        message = { ...message, ...options.queryParams };
      }

      return client.send(JSON.stringify(message));
    },
  };

  if (onConnect) onConnect(connection);

  return connection;
};

export default websocketClient;

We've added quite a bit here. Back near the top, notice that we've added an expectation for a value options.queryParams potentially being present in the options object passed as the first argument to our websocketClient function.

Because websocket connections do not allow us to pass a body like we can with an HTTP POST request, we're limited to passing connection params (information that better identifies the connection like a userId or a chatId) as a URL-safe query string. Here, we're saying "if we're passed an object of queryParams in the options, we want to convert that object into a URL-safe query string (something that looks like ?someQueryParam=thisIsAnExample).

This is where the usage of let comes in that we hinted at earlier. If we're passed queryParams in our options, we want to update our URL to include those. In this context, the "update" is to the url variable that we created. Because we want to reassign the contents of that variable to a string including our query params, we have to use the let variable (or, if you want to go old school, var). The reason why is that if we use the more familiar const (which stands for constant) and tried to run the url = '${url}?${queryString.stringify(options.queryParams)}'; code here, JavaScript would throw an error saying that we cannot reassign a constant.

Taking our queryParams object, we import the queryString package that we added earlier and use its .stringify() method to generate the string for us. So, assuming our base server URL is ws://localhost:5001/websockets and we pass an options.queryParams value equal to { channel: 'cartoons' }, our URL would be updated to equal ws://localhost:5001/websockets?channel=cartoons.

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  [...]

  let client = new WebSocket(url);

  client.addEventListener("open", () => {[...]});

  client.addEventListener("close", () => {[...]});

  const connection = {
    client,
    send: (message = {}) => {
      if (options.queryParams) {
        message = { ...message, ...options.queryParams };
      }

      return client.send(JSON.stringify(message));
    },
  };

  if (onConnect) onConnect(connection);

  return connection;
};

export default websocketClient;

Back down at the bottom of our function, we've added a new object connection as a const which includes two properties: client which is set to the client variable containing our websocket connection and send, set to a custom function that we're defining to help us send messages.

One of the core concepts in a websocket server is the ability to send messages back and forth between the client and the server (think of your websocket connection like a piece of string with two cans connected to either end). When we send messages—from either ther client or the server—we need to cast (meaning to set as or transform to a different type of data) them as a string value 'like this'.

Here, our send function is added as a convenience to help us streamline the passing of entire objects as a string. The idea here is that, when we put our code to use, upon calling our websocketClient function, we'll receive back this connection object. In our code, then, we'll be able to call connection.send({ someData: 'hello there' }) without having to stringify the object we pass in manually.

Further, in addition to stringifying our message, this code also includes any queryParams that were passed in. This is helpful because we may need to reference those values both when we handle the client connection in our websocket server, or, whenever we receive a message from a connected client (e.g., passing a userId along with a message to identify who sent it).

Just before we return connection at the bottom of our function, notice that we conditionally make a call to onConnect (the callback function that will be called after our connection is established). Technically speaking, here, we're not waiting for the actual connection to establish before we call to this callback.

A websocket connection should establish near-instantaneously, so by the time this code is evaluated, we can expect a client connection to exist. In the event that connection to a server was slow, we'd want to consider moving the call to onConnect inside of the event listener callback for the open event up above.

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  let url = settings?.websockets?.url;

  if (options.queryParams) {
    url = `${url}?${queryString.stringify(options.queryParams)}`;
  }

  let client = new WebSocket(url);

  client.addEventListener("open", () => {
    console.log(`[websockets] Connected to ${settings?.websockets?.url}`);
  });

  client.addEventListener("close", () => {
    console.log(`[websockets] Disconnected from ${settings?.websockets?.url}`);
    client = null;
  });

  client.addEventListener("message", (event) => {
    if (event?.data && options.onMessage) {
      options.onMessage(JSON.parse(event.data));
    }
  });

  const connection = {
    client,
    send: (message = {}) => {
      if (options.queryParams) {
        message = { ...message, ...options.queryParams };
      }

      return client.send(JSON.stringify(message));
    },
  };

  return connection;
};

export default websocketClient;

One more thing to sneak in. While we've set up our websocket client to send messages, we haven't yet set it up to receive messages.

When a messages is sent to connected clients (unless handled intentionally, a message sent by a websocket server will be sent to all connected clients), those clients receive that message through the message event on their client connection.

Here, we've added a new event listener for the message event. Conditionally, assuming that an actual message was sent (in the event.data field) and that we have an onMessage callback function in our options, we call that function, passing the JSON.parse'd version of the message. Remember, messages are sent back and forth as strings. Here, we're making the assumption that the message we've received from our server is a stringified object and we want to convert it into a JavaScript object.

That's it for our implementation! Now, let's put our client to use and verify that everything is working as expected.

Using the websocket client

To put our client to use, we're going to wire up a new page component in the boilerplate we cloned at the start of this tutorial. Let's create a new page at /pages/index.js now and see what we need to do to integrate our websocket client.

/pages/index.js

import React from "react";
import PropTypes from "prop-types";
import websocketClient from "../websockets/client";

import StyledIndex from "./index.css";

class Index extends React.Component {
  state = {
    message: "",
    received: [],
    connected: false,
  };

  componentDidMount() {
    websocketClient(
      {
        queryParams: {
          favoritePizza: "supreme",
        },
        onMessage: (message) => {
          console.log(message);
          this.setState(({ received }) => {
            return {
              received: [...received, message],
            };
          });
        },
        onDisconnect: () => {
          this.setState({ connected: false });
        },
      },
      (websocketClient) => {
        this.setState({ connected: true }, () => {
          this.websocketClient = websocketClient;
        });
      }
    );
  }

  handleSendMessage = () => {
    const { message } = this.state;
    this.websocketClient.send({ message });
    this.setState({ message: "" });
  };

  render() {
    const { message, connected, received } = this.state;

    return (
      <StyledIndex>
        <div className="row">
          <div className="col-sm-6">
            <label className="form-label">Send a Message</label>
            <input
              className="form-control mb-3"
              type="text"
              name="message"
              placeholder="Type your message here..."
              value={message}
              onChange={(event) =>
                this.setState({ message: event.target.value })
              }
            />
            <button
              className="btn btn-primary"
              onClick={this.handleSendMessage}
            >
              Send Message
            </button>
          </div>
          <div className="row">
            <div className="col-sm-12">
              <div className="messages">
                <header>
                  <p>
                    <i
                      className={`fas ${connected ? "fa-circle" : "fa-times"}`}
                    />{" "}
                    {connected ? "Connected" : "Not Connected"}
                  </p>
                </header>
                <ul>
                  {received.map(({ message }, index) => {
                    return <li key={`${message}_${index}`}>{message}</li>;
                  })}
                  {connected && received.length === 0 && (
                    <li>No messages received yet.</li>
                  )}
                </ul>
              </div>
            </div>
          </div>
        </div>
      </StyledIndex>
    );
  }
}

Index.propTypes = {
  // prop: PropTypes.string.isRequired,
};

export default Index;

Let's discuss the general idea here and then focus in on the websocket stuff. What we're doing here is setting up a React component that renders an input, a button, and a list of messages received from our websocket server. To demonstrate usage of our client, we're going to connect to the client and then send messages up to the server. We expect (we'll look at this later) our server to send us back a message in a ping pong fashion where the server acknowledges our message by sending back its own.

In the render() function here, we use a combination of Bootstrap (included with the boilerplate we cloned for this tutorial) and a small bit of custom CSS implemented using styled-components via the <StyledIndex /> component which we've imported at the top of our component file.

The specifics of the CSS are not important here, but make sure to add the following file at /pages/index.css.js (pay attention to the .css.js extension so the import still works in your component at /pages/index.js). The code we show next will still work without it, but it won't look like the example we show below.

/pages/index.css.js

import styled from "styled-components";

export default styled.div`
  .messages {
    background: var(--gray-1);
    margin-top: 50px;

    header {
      padding: 20px;
      border-bottom: 1px solid #ddd;
    }

    header p {
      margin: 0;

      i {
        font-size: 11px;
        margin-right: 5px;
      }

      .fa-circle {
        color: lime;
      }
    }

    ul {
      padding: 20px;
      list-style: none;
      margin: 0;
    }
  }
`;

Back in the component, we want to focus on two methods: our componentDidMount and handleSendMessage:

/pages/index.js

import React from "react";
import PropTypes from "prop-types";
import websocketClient from "../websockets/client";

import StyledIndex from "./index.css";

class Index extends React.Component {
  state = {
    message: "",
    received: [],
    connected: false,
  };

  componentDidMount() {
    websocketClient(
      {
        queryParams: {
          favoritePizza: "supreme",
        },
        onMessage: (message) => {
          console.log(message);
          this.setState(({ received }) => {
            return {
              received: [...received, message],
            };
          });
        },
        onDisconnect: () => {
          this.setState({ connected: false });
        },
      },
      (websocketClient) => {
        this.setState({ connected: true }, () => {
          this.websocketClient = websocketClient;
        });
      }
    );
  }

  handleSendMessage = () => {
    const { message } = this.state;
    this.websocketClient.send({ message });
    this.setState({ message: "" });
  };

  render() {
    const { message, connected, received } = this.state;

    return (
      <StyledIndex>
        [...]
      </StyledIndex>
    );
  }
}

Index.propTypes = {
  // prop: PropTypes.string.isRequired,
};

export default Index;

Here, in the componentDidMount function, we make a call to our websocketClient() function which we've imported from our /websockets/client.js file. When we call it, we pass the two expected arguments: first, an options object containing some queryParams, an onMessage callback function, and an onDisconnect callback, and second, an onConnect callback function that will receive our websocket client instance once it's available.

For the queryParams, here we're just passing some example data to showcase how this works.

In the onMessage callback, we take in the message (remember, this will be a JavaScript object parsed from the message string we receive from the server) and then set it on the state of our component by concatenating it with the existing messages we've received. Here, the ...received part is saying "add the existing received messages to this array." In effect, we get an array of message objects containing both the previously received messages and the message we're receiving now.

Finally, for the options, we also add an onDisconnect callback which sets the connected state on the component (we'll use this for determining a successful connection) to false if we lose the connection.

Down in the onConnect callback (the second argument passed to websocketClient()) we make a call to this.setState() setting connected to true and then—the important part—we assign the websocketClient instance passed to us via the onConnect callback and set it on the React component instance as this.websocketClient.

The reason we want to do this is down in handleSendMessage. This message is called whenever the button down in our render() method is clicked. On click, we get the current value for message (we set this on state as this.state.message whenever the input changes) and then call to this.websocketClient.send(). Remember that the send() function we're calling here is the same one we wired up and assigned to the connection object back in /websockets/client.js.

Here, we pass in our message as part of an object and expect .send() to convert that to a string before sending it up to the server.

That's the meat and potatoes of it. Down in the render() function, once our this.state.received array has some messages, we render them out as plain <li></li> tags down in the <div className="messages"></div> block.

With that, when we load up our app in the browser and visit http://localhost:5000, we should see our simple form and (assuming our websocket server is running) a "Connected" status beneath the input! If you send a message, you should see a response coming back from the server.

Note: Again, if you haven't completed the CheatCode tutorial on setting up a websocket server, make sure to follow the instructions there so you have a working server and make sure to start it up.

Wrapping up

In this tutorial, we learned how to set up a websocket client using the native in-browser WebSocket class. We learned how to write a wrapper function that establishes a connection to our server, processes query parameters, and handles all of the basic websocket events including: open, close, and message.

We also learned how to wire up our websocket client inside of a React component and how to send messages via that client from a form within our component.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode