How to Use WebSockets in a Redux Application

At some point, you might work on a React/Redux application that requires the use of WebSockets, such as for chat or live updates on a dashboard. It might be confusing at first to know where to put the WebSocket state and events relative to React components and Redux state.

Redux Middleware is a good place to handle all your WebSocket needs, which I'll lay out here.

You can also see a working implementation of this method used in this minimal open-source chat application I made with React/Redux, TypeScript, and Socket.io. I opted to just use the browser's WebSocket API for this article, though.

Prerequisites

Goals

  • Set up a Socket client and Redux middleware for handling a WebSocket connection

Using WebSockets

The WebSocket API is a Web API that makes it possible to open a connection between a client and server. If you wanted to make something like a chat application without a WebSocket, you would have to utilize polling, or continuously making API requests at an interval.

The client portion of the connection is set up with new WebSocket(url), and it handles open, close, and message events.

// Initialize WebSocket connection
const socket = new WebSocket('wss://my-websocket-url')
// Listen for open connection
socket.addEventListener('open', (event) => {
  console.log('You say hello...')
})

// Listen for messages
socket.addEventListener('message', (event) => {
  console.log('Incoming message: ', event.data)
})

// Listen for close connection
socket.addEventListener('close', (event) => {
  console.log('...and I say goodbye!')
})

// Send a message
socket.send('A message')

// Close websocket connection
socket.close()

Socket Class

I often like to make a little class API to make it slightly cleaner and easier to work with code, and not have to make any global variables. Though I'm not sure exactly why I feel that this is so much better than having const socket = new WebSocket(url) defined somewhere. But here's a class that connects, disconnects, sends messages, and handles events.

@/utils/Socket.js
class Socket {
  constructor() {
    this.socket = null
  }

  connect(url) {
    if (!this.socket) {
      this.socket = new WebSocket(url)
    }
  }

  disconnect() {
    if (this.socket) {
      this.socket.close()
      this.socket = null
    }
  }

  send(message) {
    if (this.socket) {
      this.socket.send(JSON.stringify(message))
    }
  }

  on(eventName, callback) {
    if (this.socket) {
      this.socket.addEventListener(eventName, callback)
    }
  }
}

export { Socket }

Now this can be used in basically the same way, and it makes it simple to work with multiple WebSockets, which I have needed to do on some projects. The example here is quite simple, but you might have more than just open, message and close events, and you might add other things into the class like checking for a heartbeat, applying retries, etc.

import { Socket } from '@/utils/Socket'

const socket = new Socket()socket.connect('wss://my-websocket-url')
socket.on('open', (event) => {
  console.log('You say hello...')
})

socket.on('message', (event) => {
  console.log('Incoming message: ', event.data)
})

socket.on('close', (event) => {
  console.log('...and I say goodbye!')
})

socket.send('A message')
socket.disconnect()

Creating Redux Middleware

Here is an example of Redux middleware:

const middleware = (params) => (next) => (action) => {
  const { dispatch, getState } = params

  if (action.type === 'action you want to intercept') {
    // Do something
  }

  return next(action)
}

You have access to the entire current state with getState, and you have access to dispatching an action with dispatch. Middleware is similar to a Redux thunk action.

So for the Socket middleware, you'll want to intercept the open and close events for the WebSocket that have been dispatched from elsewhere. It might be on login to the application, as with an entire chat application, or it might be on mount of a specific page, as with a live dashboard.

Using socket/connect and socket/disconnect actions, you can pass through an initialized instance of the Socket class created earlier. On socket/connect, you'll open the WebSocket connection and handle all events. On socket/disconnect, you'll close the connection.

@/middleware/socket.js
export const socketMiddleware = (socket) => (params) => (next) => (action) => {
  const { dispatch, getState } = params
  const { type } = action

  switch (type) {
    case 'socket/connect':
      socket.connect('wss://example.com')

      socket.on('open', () => {})
      socket.on('message', (data) => {})
      socket.on('close', () => {})
      break

    case 'socket/disconnect':
      socket.disconnect()
      break

    default:
      break
  }

  return next(action)
}

Now the connection should be initialized when you call socket/connect, and throughout the life of the application the WebSocket will be listening via the event handlers, until you end the connection.

Finally, you'll add the middleware to the store configuration, shown here using the Redux Toolkit method which is currently the officially preferred method.

@/store/index.js
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'

import { rootReducer } from '@/store/rootReducer'
import { Socket } from '@/utils/Socket'

export const store = configureStore({
  reducer: rootReducer,
  middleware: [socketMiddleware(new Socket()), ...getDefaultMiddleware()],})

Now wherever you decide to call the connect and disconnect functions within the React component will have the desired effect.

import React, { useEffect } from 'react'
import { useDispatch } from 'react-redux'

export const LiveDashboardPage = () => {
  const dispatch = useDispatch()

  useEffect(() => {
    dispatch({ type: 'socket/connect' })
    return () => {
      dispatch({ type: 'socket/disconnect' })    }
  }, [])
}

Conclusion

This is a very minimalist setup for getting a WebSocket connection into a Redux application, but it covers the main gist of it. Use Middleware to intercept connect and disconnect events, and listen to all the events upon initialization and handle them accordingly.

One thing to note with this particular approach is that every tab will open a new WebSocket connection. I've done some poking around but I haven't come to a definitive conclusion on whether or not 1 tab = 1 connection is the best approach, or whether using the SharedWorker API and BroadcastChannel API is a better approach. I plan to follow up this article with one about sharing a connection across multiple tabs using these APIs.

Comments