DEV Community

Cover image for The complete guide to WebSockets with React
Alex Booker for Ably

Posted on • Updated on • Originally published at ably.com

The complete guide to WebSockets with React

In this post, you’ll learn more than you ever thought you needed to know about WebSockets with React, including how to build a smooth realtime cursor experience from scratch:

Jump into the tutorial which uses React on the front and Node on the back or start with some general guidance when it comes to WebSockets with React below.

What are WebSockets?

WebSockets are a communication protocol that enable bidirectional communication between applications.

They are a great choice when two-way communication is needed such as chat and multiplayer collaboration.

Additionally, WebSockets are well-suited when you need to push fresh data from the server as soon as it’s available - like live sports score updates, updates on a package delivery, or perhaps realtime chart data.

Because they are full-duplex, information can flow in both directions simultaneously, making WebSockets an attractive option for high throughput scenarios like an online multiplayer game as well.

Wondering how WebSockets compare to alternative realtime transports like Server-Sent Events (EventSource)? I wrote all about that here.

How WebSockets work

Unlike HTTP where requests are short-lived, WebSockets enable realtime communication using a long-lived stateful connection.

Once the connection is established, it remains open until either side closes the connection.

How WebSockets work

Because these connections are long-lived you don’t want to open more than necessary as not to cause memory problems. And since one WebSocket connection has plenty of bandwidth, it’s common practice to use one connection for all your messages (a technique called multiplexing).

This leads to some interesting questions like “where should I put my connection in React?” and “how do I clean up the connection?”, both of which I will answer in this post.

WebSockets and React

WebSockets have a Web API accessible in all major web browsers, and since React is “just JavaScript” you can access it without any additional modules or React-specific code:

const socket = new WebSocket("ws://localhost:8080")

// Connection opened
socket.addEventListener("open", event => {
  socket.send("Connection established")
});

// Listen for messages
socket.addEventListener("message", event => {
  console.log("Message from server ", event.data)
});
Enter fullscreen mode Exit fullscreen mode

There is no specific React library needed to get started with WebSockets, however, you might benefit from one.

The simple and minimal WebSocket API gives you flexibility, but that also means additional work to arrive at a production-ready WebSocket solution.

When you use the WebSocket API directly, here are just some of the things you should be prepared to code yourself:

  • Authentication and authorization.
  • Robust disconnect detection by implementing a heartbeat.
  • Seamless automatic reconnection.
  • Recovering missed messages the user missed while temporarily disconnected.

Instead of reinventing the wheel, it’s usually more productive to use a general WebSocket library that provides the features listed above out of the box - this allows you to focus on building features unique to your application instead of generic realtime messaging code.

Best React WebSocket libraries

Before we look at some WebSocket libraries, it’s helpful to distinguish React and JavaScript in this context.

Since React is “just JavaScript”, you might not necessarily need a React-specific WebSocket library.

That being said, it would definitely be a plus if the WebSocket library had idiomatic React functions available such as ready-made React hooks!

There’s a bunch of WebSocket libraries out there (many of them outdated) so we previously wrote a post showing you only the best WebSocket libraries for React.

Here’s the summary:

  • React useWebSocket: A thin layer on top of the WebSocket API that features automatic reconnection and a fallback to Server-Sent Events (as long as you’ve coded support on your server). This library is specifically made for React, so it’s very natural to utilise the useWebSocket hook and all its options. The downside is that useWebSocket might not have all the features and reliability guarantees you need in production. Learn more.

  • Socket.IO: A JavaScript realtime messaging library based on WebSockets with an optional fallback to HTTP long polling in case the WebSocket connection can’t be established. Socket.IO has more features than useWebSocket, but it’s not specific to React, and there’s still work to do to ensure good performance and reliability in production. Learn more.

  • React useWebSocket with Socket.IO: useWebSocket actually works with Socket.IO, meaning you might be able to use them together in your React project. I haven’t tested this extensively, but it looks promising!

  • Ably: A realtime infrastructure platform featuring first-class React client support. With useWebSocket or Socket.IO, you need to host your own WebSocket server. That sounds simple enough, but it’s actually a big burden to manage your own WebSocket backend. With Ably, you create an account, and all the messages route through the Ably global infrastructure with the lowest possible latency. Instead of worrying about uptime or if your messages will be delivered exactly-once and in the correct order, you can just plug into the React hook and focus on building the features that actually matter to your users. Learn more.

React WebSocket best practices

When using WebSockets with React, they become an important channel for new information to flow. Ideally, any component that needs to should be able to send and receive data using the shared connection.

At the same time, you don’t want to create a mess by prop drilling, or introduce brittleness by making the WebSocket accessible to every component. When you do that, you violate encapsulation, meaning any component can alter the WebSocket object, potentially causing unexpected behavior elsewhere in the code.

When I was learning about WebSockets in React, this caused me a bit of anxiety! I went looking for a definitive best practice but, as it happens, there isn’t a universal “right” answer. It depends on what you’re building and the specific shape of your application.

In the next section, I’ll share what I wish I had - a tour of the options to structure your WebSocket React code, with some considerations for and against (plus, some best practices).

Where do I put the WebSocket in React?

Should you put the connection in a component, use context, create a hook, or something else entirely?

Here’s what you need to know.

Use the useWebSocket hook

While there are advantages to managing the connection yourself, for most of us, all of our “WebSockets-with-React” answers are but an npm install away.

useWebSocket is an open source module with 1.2K stars (so you know it’s popular), and it provides a well-thought-out hook to establish a WebSocket connection and manage the connection lifecycle.

Here is an example of it in action:

import useWebSocket, { ReadyState } from "react-use-websocket"

export const Home = () => {
  const WS_URL = "ws://127.0.0.1:800"
  const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(
    WS_URL,
    {
      share: false,
      shouldReconnect: () => true,
    },
  )

  // Run when the connection state (readyState) changes
  useEffect(() => {
    console.log("Connection state changed")
    if (readyState === ReadyState.OPEN) {
      sendJsonMessage({
        event: "subscribe",
        data: {
          channel: "general-chatroom",
        },
      })
    }
  }, [readyState])

  // Run when a new WebSocket message is received (lastJsonMessage)
  useEffect(() => {
    console.log(`Got a new message: ${lastJsonMessage}`)
  }, [lastJsonMessage])

  return <Chat lastJsonMessage={lastJsonMessage} />
}
Enter fullscreen mode Exit fullscreen mode

Apart from providing an idiomatic abstraction, useWebSocket also has some handy options like share.

When set to true, useWebSocket will share the connection to the same endpoint no matter where you call the hook.

This makes it straightforward to reuse the connection from different instances of the component and generally access the WebSocket anywhere you need.

Note - there is no need to cleanup the WebSocket connection since useWebsocket does that for you when the component unmounts. Very handy!

In the top-level component

In the previous section, we looked at how a third-party module called useWebSocket can help.

In case you don’t want to depend on a library, next let's explore how to manage the WebSocket instance directly with idiomatic React code.

For simple applications, it’s perfectly appropriate to open the WebSocket connection directly in the top-level component and utilize useRef (useMemo will also work) to hold on to the connection instance between renders:

export const Home = () => {
  const connection = useRef(null)

  useEffect(() => {
    const socket = new WebSocket("ws://127.0.0.1:800")

    // Connection opened
    socket.addEventListener("open", (event) => {
      socket.send("Connection established")
    })

    // Listen for messages
    socket.addEventListener("message", (event) => {
      console.log("Message from server ", event.data)
    })

    connection.current = ws

    return () => connection.close()
  }, [])

  return <Chat />
}
Enter fullscreen mode Exit fullscreen mode

As a best practice, subscribe to the WebSocket events from the top-level component, update top-level state in response to new events, and pass the state down as a prop.

While it might be tempting to pass the WebSocket object down as a prop so you can attach event handlers or call send directly, that could become hairy compared to managing your WebSocket connection in one place.

In the likely event a child component needs to send data, you can pass a callback function.

Context API

If you have a lot of components in your component hierarchy and many of them need access to the WebSocket, passing props from the top-level component can become really unwieldy really quickly.

Context is a React feature specifically invented to solve this problem.

It provides a way to share data through the component tree without having to pass props down manually from parent to child component and is perfect for state that’s considered “global” in the application, such as the current authenticated user, theme, preferred language, or - you guessed it - a WebSocket connection.

Here is an example of a WebSocket Context provider, borrowed from Kian Musser who wrote a very good post on the subject:

export const WebsocketContext = createContext(false, null, () => {})
//                                            ready, value, send

// Make sure to put WebsocketProvider higher up in
// the component tree than any consumer.
export const WebsocketProvider = ({ children }) => {
  const [isReady, setIsReady] = useState(false)
  const [val, setVal] = useState(null)

  const ws = useRef(null)

  useEffect(() => {
    const socket = new WebSocket("wss://echo.websocket.events/")

    socket.onopen = () => setIsReady(true)
    socket.onclose = () => setIsReady(false)
    socket.onmessage = (event) => setVal(event.data)

    ws.current = socket

    return () => {
      socket.close()
    }
  }, [])

  const ret = [isReady, val, ws.current?.send.bind(ws.current)]

  return (
    <WebsocketContext.Provider value={ret}>
      {children}
    </WebsocketContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

And a consumer:

// Very similar to the WsHook component above.
export const WsConsumer = () => {
  const [ready, val, send] = useContext(WebsocketContext); // use it just like a hook

  useEffect(() => {
    if (ready) {
      send("test message");
    }
  }, [ready, send]); // make sure to include send in dependency array

  return (
    <div>
      Ready: {JSON.stringify(ready)}, Value: {val}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

In a custom hook

Hooks in React are what you make of them.

At a minimum, a custom WebSocket hook would provide an idiomatic way to abstract your WebSocket-related code in a single place.

As mentioned earlier, WebSocket is a simple and minimal API and there’s always more work to do on top to achieve a minimum-viable production implementation.

For example, you’ll need a way to detect disconnections and automatically reconnect. Additionally, you may want to associate some state with the connection, like a list of users currently connected (presence).

It would make a lot of sense to abstract that code away in a hook instead of convoluting your components and risking messy code duplication.

Here’s an example from Kian's post:

export const useWs = ({ url }) => {
  const [isReady, setIsReady] = useState(false)
  const [val, setVal] = useState(null)

  const ws = useRef(null)

  useEffect(() => {
    const socket = new WebSocket(url)

    socket.onopen = () => setIsReady(true)
    socket.onclose = () => setIsReady(false)
    socket.onmessage = (event) => setVal(event.data)

    ws.current = socket

    return () => {
      socket.close()
    }
  }, [])

  // bind is needed to make sure `send` references correct `this`
  return [isReady, val, ws.current?.send.bind(ws.current)]
}
Enter fullscreen mode Exit fullscreen mode

And the usage:

export const WsComponent = () => {
  const [ready, val, send] = useWs("wss://echo.websocket.events/")

  useEffect(() => {
    if (ready) {
      send("test message")
    }
  }, [ready, send]) // make sure to include send in dependency array

  return (
    <div>
      Ready: {JSON.stringify(ready)}, Value: {val}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

While hooks are an idiomatic and clean way to share logic between components, they don’t solve the problem where you need to access the WebSocket connection throughout your application. You’d still need to lean on props (or Context) for that.

Alternatively, you could take a page from useWebSocket and implement the singleton pattern, such that the hook only maintains a single connection, regardless of how many times it’s instantiated.

How to use WebSockets with React and Node

We’ve covered the theory behind WebSockets. The aim now is to bring it all together by showing you concrete examples of the fundamentals in action.

What you’ll build

In this tutorial, you will learn how to build a live cursors experience with WebSockets and React:

What you’ll build

You will learn how to:

  • Open a WebSocket connection using useWebSocket.
  • Send and receive messages.
  • Implement a “who’s online” feature (presence).
  • Broadcast messages to every connected client.

Remember, the fundamental ideas in this post can be adapted to work for chat, online updates, or even a full-blown realtime collaborative experience.

If you want to jump ahead and grab the code, please go ahead! It's all on GitHub. Maybe leave a star on the way by?

Implementing the WebSocket server in Node

Every WebSocket application has two components - the server and the client.

Since the client won’t be able to do much until it has a server to connect to, let’s build the server first!

You can implement a WebSocket server in almost any programming language but since React uses JavaScript, we will use Node and the ws module.

Start by creating a new folder called react-websockets-tutorial and a subfolder called server:

mkdir react-websockets-tutorial
cd react-websockets-tutorial
mkdir server
cd server
Enter fullscreen mode Exit fullscreen mode

Making sure you’re inside the server subfolder, run npm init followed by npm install:

npm init -y
npm install –-save ws uuid 
Enter fullscreen mode Exit fullscreen mode

npm init creates a package.json file while npm install ws uuid download the modules we’ll need to reference from the code.

After that, create index.js and paste the whole server code. I’ll explain it all below.

const { WebSocketServer } = require("ws")
const http = require("http")
const uuidv4 = require("uuid").v4
const url = require("url")

const server = http.createServer()
const wsServer = new WebSocketServer({ server })

const port = 8000
const connections = {}
const users = {}

const handleMessage = (bytes, uuid) => {
  const message = JSON.parse(bytes.toString())
  const user = users[uuid]
  user.state = message
  broadcast()

  console.log(
    `${user.username} updated their updated state: ${JSON.stringify(
      user.state,
    )}`,
  )
}

const handleClose = (uuid) => {
  console.log(`${users[uuid].username} disconnected`)
  delete connections[uuid]
  delete users[uuid]
  broadcast()
}

const broadcast = () => {
  Object.keys(connections).forEach((uuid) => {
    const connection = connections[uuid]
    const message = JSON.stringify(users)
    connection.send(message)
  })
}

wsServer.on("connection", (connection, request) => {
  const { username } = url.parse(request.url, true).query
  console.log(`${username} connected`)
  const uuid = uuidv4()
  connections[uuid] = connection
  users[uuid] = {
    username,
    state: {},
  }
  connection.on("message", (message) => handleMessage(message, uuid))
  connection.on("close", () => handleClose(uuid))
})

server.listen(port, () => {
  console.log(`WebSocket server is running on port ${port}`)
})
Enter fullscreen mode Exit fullscreen mode

Congratulations! 🎉 If you now run node index.js, you will be running a fully-functioning WebSocket server:

fully-functioning WebSocket server

It’s not that useful to run the server without a client and, of course, you’re here to understand the code (not just paste it!), but I think it’s worth pointing out how quickly we can get up and running.

Let’s break down the code from bottom to top.

First, we create a HTTP server and a WebSocket server before listening for incoming connections:

// Normally these constants live at the top of the file but I pasted them here for context :)
const server = http.createServer()
const wsServer = new WebSocketServer({ server })

server.listen(port, () => {
  console.log(`WebSocket server is running on port ${port}`);
})
Enter fullscreen mode Exit fullscreen mode

Even though WebSocket is a separate protocol from HTTP, the WebSocket upgrade handshake happens over HTTP, meaning we need both.

Next, we add an event handler for incoming connections.

Here, things get much more interesting:

wsServer.on('connection', (connection, request) => {
  const { username }  = url.parse(request.url, true).query
  const uuid = uuidv4()
  connections[uuid] = connection
  users[uuid] = {
    username
  }
  connection.on('message', message => handleMessage(message, uuid));
  connection.on('close', () => handleClose(uuid));
});
Enter fullscreen mode Exit fullscreen mode

In the client code, we will eventually connect to the server using a URL like "ws://localhost:8080?username=Alex".

Step one is to extract the { username } from the query string so we know who is connecting.

Next, we generate a universally unique identifier (UUID) by which to reference the user.

We use a UUID as a key instead of the username in case two users with the same username connect.

So far, so good!

The next two lines require a bit more explanation:

 connections[uuid] = connection
 users[uuid] = {
   username,
   state: { }
}
Enter fullscreen mode Exit fullscreen mode

First, we add the connection to the connections dictionary.

This will be handy when we want to send a message to all connected clients since we can now loop over connections and call connection.send on each.

Next, we create and add a user to the users dictionary. Here, we associate information with the user, such as their username.

Additionally, we initialize an empty state object which we will later populate with information about the user’s status or attributes.

For the realtime cursor experience we’re building, we will eventually update the user’s state with their cursor position, but this pattern I call presence can be adapted to associate any information with the user.

For example, if you are building a chat application, you could add a typingStatus and onlineStatus (personally, I look forward to being "out for lunch 🍔").

You might be wondering - why create both a connections and a users dictionary?

We could maintain just a connections dictionary then associate the user with the connection.

On the surface, it looks simpler:

connection.user = { 
  username,
  state: { } 
}
connections[uuid] = connection
Enter fullscreen mode Exit fullscreen mode

This is valid, but I see connection state and user presence information as discrete concepts and choose to represent them that way in code.

From a practical perspective, in a moment, we will JSON.stringify (serialize) and broadcast the users object to all connected clients. If we tried to serialize the connections object, we’d get a really unfocused string with metadata about the ws connection.

Speaking of the broadcast function, here it is:

const broadcast = () => {
  Object
    .keys(connections)
    .forEach(uuid => {
      const connection = connections[uuid]
      const message = JSON.stringify(users)
      connection.send(message)
    })
}
Enter fullscreen mode Exit fullscreen mode

broadcast enumerates the connection dictionary and sends each client an up-to-date view of who’s connected and their state.

With just one call to broadcast, connected clients will receive a list of who’s connected (making it possible to build a “who’s online list”), as well as state about each user.

Most importantly - the client will see a snapshot of every user's cursor position so that, in turn, it can render a visual cursor.

Still working our way up the server index.js file, let’s look at the event handlers next:

const handleMessage = (bytes, uuid) => {
  const message = JSON.parse(bytes.toString())
  users[uuid].state = message
  broadcast()
}

const handleClose = uuid => {
  delete connections[uuid]
  delete users[uuid]
  broadcast()
}
Enter fullscreen mode Exit fullscreen mode

handleClose at the bottom deletes the connection and user before broadcasting the users object to all clients (excluding the one that just closed since we deleted it).

Since the user will have been deleted from users, the client will basically get the message that someone disconnected.

In an ideal world, we might broadcast a named event like “user_gone” with information about the disconnected user.

For the purposes of this tutorial, however, we will leave it up to the client to spot the difference between the previous users object and the new one to detect who exactly disconnected.

Note that handleMessage is run every time a message is received from the client. The only message the server is expecting in this example code is a state update message.

Whenever the client wants to update the user’s state (for example, the cursor position), it sends a message to the server.

In this handler, the server overwrites user.state with whatever message object it received.

As your application evolves, you will surely want to handle different types of messages and I reccomend you validate inputs to avoid WebSocket security issues.

Understanding the WebSocket server

Even though the server code is short, there’s quite a lot going on.

In the following video I quickly recap how the server works by sending and subscribing to data using an API platform Postman.

They make it possible to send and receive messages over a WebSocket connection.

By thoroughly understanding the shape of the WebSocket endpoint, it will become a lot easier to understand what needs doing on the client in the next section.

Implementing the WebSocket client in React with useWebSocket

Now the server is up and running, we can shift our attention to the React client where we’ll solve some interesting challenges around how to structure the WebSocket code, smoothly render the cursors over the network (spoiler - we are going to animate them!), and strike a balance between frequency of cursor updates and network efficiency.

First things first, cd back to the project root and scaffold a React application called “client” with vite:

cd ..
npm create vite@latest client --template react
cd client
Enter fullscreen mode Exit fullscreen mode

Making sure you’re inside the ./client subfolder, run npm install:

npm install react-use-websocket lodash.throttle perfect-cursors
Enter fullscreen mode Exit fullscreen mode

For the purposes of a tutorial, it makes sense to install everything up front.

The function of each module will become clearer as we progress through the tutorial but here’s a quick summary so you know what to expect:

  • React-use-websocket: A React hook for WebSocket communication (click here and jump up the page if you want more information).
  • Lodash.throttle: Used to invoke a function at most once every X milliseconds.
  • Perfect-cursors: Plots a smooth curve between two or more cursor positions using spline interpolation. We can then animates the cursor along said curve to prevent the cursor jumping between throttled updates.

Creating the login screen

When a user opens the app, we first want to identify them by a username:

Creating the login screen

In a new file called components/Login.jsx:

import { useState } from "react"

export function Login({ onSubmit }) {
  const [username, setUsername] = useState("")
  return (
    <>
      <h1>Welcome</h1>
      <p>What should people call you?</p>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          onSubmit(username)
        }}
      >
        <input
          type="text"
          value={username}
          placeholder="username"
          onChange={(e) => setUsername(e.target.value)}
        />
        <input type="submit" />
      </form>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Next, create a placeholder file called Home.jsx:

export function Home() {
}
Enter fullscreen mode Exit fullscreen mode

Then paste the following in ./index.js:

import React, { useState } from "react"
import ReactDOM from "react-dom/client"
import { Home } from "./components/Home"
import { Login } from "./components/Login"

const App = () => {
  const [username, setUsername] = useState("")

  return username ? (
    <Home username={username} />
  ) : (
    <Login onSubmit={setUsername} />
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<App />)
Enter fullscreen mode Exit fullscreen mode

As a reminder, index.is is the entry-point for our clien - it’s the first code that runs, and we’ll use this opportunity to determine which screen to show used based on the application’s state.

Here, we check if the username has been set. If it hasn’t, we return (render) the Login component. Otherwise, we render the Home component and pass the username as a prop.

In the next step, we’ll use this username prop to identify the user to the server when establishing the WebSocket connection.

Sending cursor positions over WebSockets

When it comes to rendering realtime cursors, we can divide the problem in two parts:

  1. Sending the cursor positions.
  2. Receiving the cursor positions and efficiently rendering a cursor for each user.

In this section, we’ll focus on the first part of the problem - connecting to the WebSocket server and sending the cursor position when it moves.

Let’s start with a code dump before explaining it line by line.

In Home.jsx (the empty placeholder component we defined before), replace the entire contents with the following:

import useWebSocket from "react-use-websocket"
import React, { useEffect, useRef } from "react"
import throttle from "lodash.throttle"

export function Home({ username }) {
  const WS_URL = `ws://127.0.0.1:8000`

  const { sendJsonMessage } = useWebSocket(WS_URL, {
    queryParams: { username },
    share: true,
  })

  const THROTTLE = 50
  const sendJsonMessageThrottled = useRef(throttle(sendJsonMessage, THROTTLE))

  useEffect(() => {
    sendJsonMessage({
      x: 0,
      y: 0,
    })

    window.addEventListener("mousemove", (e) => {
      sendJsonMessageThrottled.current({
        x: e.clientX,
        y: e.clientY,
      })
    })
  }, [])

  return <h1>Home</h1>
}
Enter fullscreen mode Exit fullscreen mode

First, we import the useWebSocket hook and call it with the WebSocket server’s address - in this case, "ws://127.0.0.1:8000".

Tip - make sure the WebSocket server is running in the background and double-check the port in the server the console output matches the one here (8000).

Additionally, we pass a queryParams option with the username. In a nutshell, useWebSocket will take the username property and append the value to the WebSocket server URL like this "ws://127.0.0.1:8000?username=Alex".

If you recall, the server has been coded to extract the username query parameter to identify the user.

We also pass share: true as an option to useWebSocket.

This isn’t strictly necessary for this project, however, I include to illustrate a key feature of useWebSocket that will probably be of use in your project.

When share is set to true, you can call useWebSocket(WS_URL) anywhere in your React application and - if useWebSocket already has a connection to that specific URL, it will share the connection between instances instead of opening a new one.

This is true if you create multiple instances of the same component (potentially simplifying the code since you can just call useWebSocket and not worry about managing the connection yourself), or, if you need to access the connection from a completely separate component (another screen, for example).

As you can see, setting up the WebSocket connection with useWebSocket is easy enough.

The unique code in this component lies within the useEffect hook.

Here, we call useEffect with an empty [], which effectively says “call me once when the component first mounts only”:

 const THROTTLE = 50
    const sendJsonMessageThrottled = useRef(throttle(sendJsonMessage, THROTTLE))

    useEffect(() => {
      sendJsonMessage({
        x: 0, y: 0
    })

    window.addEventListener('mousemove', e => {
      sendJsonMessageThrottled.current({
        x: e.clientX,
        y: e.clientY
      })
    })
  }, [])
Enter fullscreen mode Exit fullscreen mode

In the above snippet, we listen to the mouse movement and send the cursor X and Y position to the server.

In a perfect world, cursor updates would happen with zero latency and arrive at the same rate as the user’s monitor.

In practice, however, it makes more sense to “throttle” the updates to roughly one every 50-80 milliseconds.

Why 50-80 milliseconds?

Even briefly wiggling the cursor on the screen could potentially fire hundreds of events in a few milliseconds (maybe thousands if you have a high mouse sensitivity), and that would put undue strain on the server, especially coming from tens, hundreds, or thousands of users.

At Ably, we built a tool called Spaces to specifically help enable realtime collaborative features like realtime cursors (user presence too). Our thorough user experience testing revealed 50-80 milliseconds is the sweet spot between performance and efficiency.

This isn't a post about Spaces (I just think realtime cursors are cool) but we apply that learning here by creating a custom function called sendJsonMessageThrottled.

sendJsonMessageThrottled will only allow the message to be sent maximally once per every 50 milliseconds and it achieves this by using the lodash throttle function.

A key point about sendJsonMessageThrottled is that we hold a reference to the throttled function using useRef.

If not for useRef (useCallback would work as well by the way), we would be calling throttle every time the renders (this happens many times per second). This isn’t only quite inefficient, but it would break the throttle function because we would also be starting the internal 50ms timer from scratch every render.

With the server running in the background, it should now be possible to open the client, “login”, and observe the cursor position being received and logged in the WebSocket server console every 50 milliseconds or so. As you can see, the updates are still plenty frequent even though they're throttled:

Feel free to open multiple tabs and watch the messages print in the console - it’s quite fun!

The next step is, of course, to subscribe to the server broadcasts and render the cursors, but herein lies a problem we must address in the next section.

Rendering cursors with perfect-cursors

Sending a cursor update every time the cursor twitches isn’t sustainable, so we throttle the update by 50 milliseconds.

This is frequent enough that the cursor is basically realtime, but it also means the cursor might appear to jump or twitch when rendered due to the 50 millisecond gap.

Enter perfect-cursors.

Perfect-cursors is a handy library by Steve Ruiz, who is the creator of an online whiteboard called tldraw. I mention this because tldraw actually uses perfect-cursors under the hood, which is a testament to its production-readiness!

Perfect-cursors is a hook that can blend the cursors between two points.

Ordinarily, if you move your cursor from [0, 0] to [0, 500], it would appear to jump across the screen.

With perfect-cursors, it becomes possible to gently animate the cursor from [0, 0] to [0, 500] basically giving the illusion that the cursor is updated in true realtime.

In this section, we’ll quickly scaffold perfect-cursors before going back to Home.jsx to import and use it.

We already "npm installed" perfect-cursors at the beginning of this section so no need to worry about that!

Start by creating a file called components/Cursor.jsx:

// Source: https://github.com/steveruizok/perfect-cursors

import * as React from "react"
import { usePerfectCursor } from "../hooks/usePerfectCursor"

export function Cursor({ userId, point }) {
  const rCursor = React.useRef(null)

  const animateCursor = React.useCallback((point) => {
    const elm = rCursor.current
    if (!elm) return
    elm.style.setProperty(
      "transform",
      `translate(${point[0]}px, ${point[1]}px)`,
    )
  }, [])

  const onPointMove = usePerfectCursor(animateCursor)

  React.useLayoutEffect(() => onPointMove(point), [onPointMove, point])

  return (
    <svg
      ref={rCursor}
      style={{
        position: "absolute",
        top: -15,
        left: -15,
        width: 35,
        height: 35,
      }}
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 35 35"
      fill="none"
      fillRule="evenodd"
    >
      <g fill="rgba(0,0,0,.2)" transform="translate(1,1)">
        <path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" />
        <path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
      </g>
      <g fill="white">
        <path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" />
        <path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
      </g>
      <g fill={"red"}>
        <path d="m19.751 24.4155-1.844.774-3.1-7.374 1.841-.775z" />
        <path d="m13 10.814v11.188l2.969-2.866.428-.139h4.768z" />
      </g>
    </svg>
  )
}
Enter fullscreen mode Exit fullscreen mode

Then a file called hooks/useCursor.jsx:

// Source: https://github.com/steveruizok/perfect-cursors

import { PerfectCursor } from "perfect-cursors"
import * as React from "react"

export function usePerfectCursor(cb, point) {
  const [pc] = React.useState(() => new PerfectCursor(cb))

  React.useLayoutEffect(() => {
    if (point) pc.addPoint(point)
    return () => pc.dispose()
  }, [pc])

  const onPointChange = React.useCallback((point) => pc.addPoint(point), [pc])

  return onPointChange
}
Enter fullscreen mode Exit fullscreen mode

For more information on perfect-cursors, you can check out the official documentation over on GitHub.

Now Cursor.jsx is set up, we can reference it from our Home component.

Receiving cursor positions over WebSockets

Okay, time to receive cursor updates from the server and render them!

Replace the entirety of Home.jsx with:

import { Cursor } from "./Cursor"
import useWebSocket from "react-use-websocket"
import React, { useEffect, useRef } from "react"
import throttle from "lodash.throttle"

const renderCursors = (users) => {
  return Object.keys(users).map((uuid) => {
    const user = users[uuid]
    return (
      <Cursor key={uuid} userId={uuid} point={[user.state.x, user.state.y]} />
    )
  })
}

export function Home({ username }) {
  const WS_URL = `ws://127.0.0.1:8000`
  const { sendJsonMessage, lastJsonMessage } = useWebSocket(WS_URL, {
    share: true,
    queryParams: { username },
  })

  const THROTTLE = 50
  const sendJsonMessageThrottled = useRef(throttle(sendJsonMessage, THROTTLE))

  useEffect(() => {
    sendJsonMessage({
      x: 0,
      y: 0,
    })
    window.addEventListener("mousemove", (e) => {
      sendJsonMessageThrottled.current({
        x: e.clientX,
        y: e.clientY,
      })
    })
  }, [])

  if (lastJsonMessage) {
    return <>{renderCursors(lastJsonMessage)}</>
  }
}
Enter fullscreen mode Exit fullscreen mode

Since we are replacing the entire file, note - this snippet contains all the same code to send updates that we discussed above. Additionally, we import the Cursor component, subscribe to updates, and render a cursor for each connected user.

Let’s break it down.

const { sendJsonMessage, lastJsonMessage } = useWebSocket(WS_URL, {
  share: true,
  queryParams: { username },
})
Enter fullscreen mode Exit fullscreen mode

Subscribing to realtime updates with useWebSocket is easy enough. We just need to reference lastJsonMessage, as shown above.

Every time useWebSocket receives an update, it updates lastJsonMessage.

This behaves like a state variable returned by useState, meaning every time lastJsonMessage changes, the component will render, allowing us to reference the newest lastJsonMessage value and execute some rendering logic.

In the following snippet we check if lastJsonMessage has been set yet (it won’t be until the first WebSocket update is received), and, if it is, we call renderCursors:

if (lastJsonMessage) {
  return <>{renderCursors(lastJsonMessage)}</>
}
Enter fullscreen mode Exit fullscreen mode
const renderCursors = (users) => {
  return Object.keys(users).map((uuid) => {
    const user = users[uuid]
    return (
      <Cursor 
        key={uuid} 
        userId={uuid}
        point={[user.state.x, user.state.y]}  
      />
    )
  })
}
Enter fullscreen mode Exit fullscreen mode

As a reminder, every time a user joins, leaves, or updates their cursor position, the server broadcasts a dictionary (object) of users and their state.

renderCursors takes this object, enumerates it, and returns a <Cursor /> component for each user, which are then subsequently rendered on the screen.

Rendering a “who’s online” list

As a final touch, let’s leverage the list of users to render a crude “who’s online list”.

With some UI work, it could look something like this but we will keep it simple and render just plaintext as this tutorial draws to a close:

How who's online list could look

First, open Home.jsx and create a function called renderUsersList. I suggest pasting it below renderCursors, like so:

// do not paste
const renderCursors = users => {
   // do not paste
}

// paste this *below* renderCursors
const renderUsersList = users => {
  return (
    <ul>
      {Object.keys(users).map(uuid => {
        return <li key={uuid}>{JSON.stringify(users[uuid])}</li>
      })}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

In the same Home.jsx file, replace:

if (lastJsonMessage) {
  return <>{renderCursors(lastJsonMessage)}</>
}
Enter fullscreen mode Exit fullscreen mode

With:

if (lastJsonMessage) {
  return (
    <>
      {renderUsersList(lastJsonMessage)}
      {renderCursors(lastJsonMessage)}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now when you run the application and connect from multiple tabs (or computers), you should see a textual representation of who’s currently connected and their state.

Note how if you disconnect, the user is removed from the list, making it trivial to build a “who’s online list”.

Conclusion

In this post, you learned how to go realtime with React and WebSockets.

Rather than jump straight into the tutorial, we started with a focus on the fundamentals. This way, no matter what specific shape your application takes, you can feel confident about the best way to manage the WebSocket connection in React.

Realtime cursors are a fun example, and they do a good job illustrating how WebSockets and React make light work of intensive realtime updates, such as a cursor position, which updates very frequently.

Don’t forget - the key learnings in this post can be applied to all manner of realtime use cases such as chat, notifications, and graphs!

Looking to add a realtime experience to your React application?

In this post, we built a fullstack JavaScript WebSocket application that works great on localhost or with a small set of users.

Ensuring WebSocket connections work reliably with low latency and high uptime in demanding production environments is a much harder task, but it doesn’t have to be - you can let Ably handle the backend WebSocket infrastructure for you.

With Ably, WebSocket messages are routed through the Ably global infrastructure to ensure the lowest possible roundtrip latency and the highest percentile of uptime (Ably can legitimately offer a 99.999% uptime SLA).

The best part?

The Ably JavaScript SDK makes working with Ably from React just as easy as useWebSocket does, except it also supports, presence and history - and provides quality of service guarantees client-side libraries cannot like guaranteed message ordering and exactly-once semantics:

const { channel, ably } = useChannel("your-channel-name", (message) => {
  console.log(message)
})

channel.publish("test-message", { text: "message text" })
const { presenceData, updateStatus } = usePresence(
  "your-channel-name",
  "initial state",
)
updateStatus("out for lunch")
Enter fullscreen mode Exit fullscreen mode

Create a free account (no credit card required) or learn how to send and receive realtime messages and display users' presence status with Ably and React here.

Top comments (0)