Socket.io Middleware for Redux Store Integration

Published: July 1, 2023

For real-time web apps, WebSockets is the way to go for data exchange between clients and servers. Simplifying the process even further, Socket.io is a popular and indispensable library for implementing it in your apps, and can be seamlessly integratated into your Redux store using middleware.

Middleware to Hold Socket.io in Redux Store

When developing a Redux-powered application, determining where to place persistent connections like WebSockets is crucial. According to the Redux documentation and best practices, middleware is the ideal location for handling such connections. Several reasons support this choice:

  1. Middleware exists throughout the entire lifetime of the application, ensuring consistent and reliable connection handling.
  2. For most use cases, a single instance of a connection should suffice for the entire app, and middleware provides an easy way to manage and share this instance.
  3. Middleware has access to all dispatched actions, allowing it to intercept and transform actions into WebSocket messages and dispatch new actions when messages are received.
  4. WebSocket connection instances are not serializable, meaning they should not be stored in the Redux store state itself.

Placing WebSockets in Redux middleware offers a clear separation of concerns and allows for efficient communication between Redux actions and WebSocket messages. It ensures that the application maintains a clean and scalable architecture.

Understanding Redux Middleware

Before diving deeper into Socket.io integration, let's briefly explore Redux middleware's role. Middleware is a powerful extension point that intercepts actions between dispatch and the reducer. This enables developers to augment, modify, or transform actions before they reach the reducer, making it an ideal location for handling operations like WebSocket communication.

The Socket Factory

SocketFactory.ts is responsible for creating and returning a single instance of the SocketConnection class using the singleton pattern. This is the class that will hold our socket connection

SocketFactory.ts
"use client";
import { io, Socket } from "socket.io-client";
 
export interface SocketInterface {
  socket: Socket;
}
 
class SocketConnection implements SocketInterface {
  public socket: Socket;
  public socketEndpoint = process.env.NEXT_PUBLIC_WEBSOCKET_URL;
  // The constructor will initialize the Socket Connection
  constructor() {
    this.socket = io(this.socketEndpoint);
  }
}
 
let socketConnection: SocketConnection | undefined;
 
// The SocketFactory is responsible for creating and returning a single instance of the SocketConnection class
// Implementing the singleton pattern
class SocketFactory {
  public static create(): SocketConnection {
    if (!socketConnection) {
      socketConnection = new SocketConnection();
    }
    return socketConnection;
  }
}
 
export default SocketFactory;

The SocketFactory class is responsible for implementing the singleton pattern. The singleton pattern ensures that only one instance of the SocketConnection class is created and used throughout the application. The SocketFactory has one static method, create(), which is used to instantiate and return the SocketConnection instance if it doesn't already exist. If an instance already exists, it will return the existing instance instead of creating a new one.

The Middleware Itself: Handling Socket.io in Redux

Middleware allows us to add logic between the moment the user dispatches actions and when it reaches the reducer:

socketMiddleware.ts
import { Middleware } from "redux";
// Actions
import {
  connectionEstablished,
  joinRoom,
  leaveRoom,
  initSocket,
  connectionLost,
} from "@/data/Features/socket/socketSlice";
import { setPrice } from "@/data/Features/price/priceSlice";
// Socket Factory
import SocketFactory from "@/lib/SocketFactory";
import type { SocketInterface } from "@/lib/SocketFactory";
// Types
import { IPriceMessage } from "@/types/socket";
 
enum SocketEvent {
  Connect = "connect",
  Disconnect = "disconnect",
  // Emit events
  JoinRoom = "join-room",
  LeaveRoom = "leave-room",
  // On events
  Error = "err",
  Price = "price",
}
 
const socketMiddleware: Middleware = (store) => {
  let socket: SocketInterface;
 
  return (next) => (action) => {
    // Middleware logic for the `initSocket` action
    if (initSocket.match(action)) {
      if (!socket && typeof window !== "undefined") {
        // Client-side-only code
        // Create/ Get Socket Socket
        socket = SocketFactory.create();
 
        socket.socket.on(SocketEvent.Connect, () => {
          store.dispatch(connectionEstablished());
        });
 
        // handle all Error events
        socket.socket.on(SocketEvent.Error, (message) => {
          console.error(message);
        });
 
        // Handle disconnect event
        socket.socket.on(SocketEvent.Disconnect, (reason) => {
          store.dispatch(connectionLost());
        });
 
        // Handle all price events
        socket.socket.on(SocketEvent.Price, (priceMessage: IPriceMessage) => {
          store.dispatch(setPrice(priceMessage.value));
        });
      }
    }
 
    // handle the joinRoom action
    if (joinRoom.match(action) && socket) {
      let room = action.payload.room;
      // Join room
      socket.socket.emit(SocketEvent.JoinRoom, room);
      // Then Pass on to the next middleware to handle state
      // ...
    }
 
    // handle leaveRoom action
    if (leaveRoom.match(action) && socket) {
      let room = action.payload.room;
      socket.socket.emit(SocketEvent.LeaveRoom, room);
      // Then Pass on to the next middleware to handle state
      // ...
    }
    next(action);
  };
};
 
export default socketMiddleware;

By accessing the entire current store with store and the ability to dispatch actions with dispatch, middleware functions similarly to Redux thunk actions. The socketMiddleware intercepts dispatched actions like initSocket, joinRoom, and leaveRoom, originating from different parts of the app, such as on login or page mount (maybe a live dashboard etc.).

Here specifically in the above code, we listen to the SocketEvent.Price event that the backend emits. We also Emit the SocketEvent.JoinRoom event when the user dispatches the joinRoom action, as well as the SocketEvent.LeaveRoom when the user dispatches the leaveRoom action. All of the messages from our backend have a particular data structure, so we creat an interface that matches it with IPriceMessage.

next(action) is also a crucial thing to understand in the above code. Since we can have multiple middlewares, the next function invokes the next one in the chain. If there is no middleware left, it dispatches the action. Here, it's used to pass on the request to the reducers which were called, which could be useful, for example if we also wanted to manage the state of which rooms a client is connected to.

⚠️

If we don't call the next function, the action wouldn’t be dispatched.

The Socket Slice

To manage Socket.io connections within the Redux store, a separate slice is utilized:

socketSlice.ts
// Slice of store that manages Socket connections
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
 
export interface SocketState {
  isConnected: boolean;
  rooms: string[];
}
 
const initialState: SocketState = {
  isConnected: false,
  rooms: [],
};
 
type RoomAction = PayloadAction<{
  room: string;
}>;
 
// Now create the slice
const socketSlice = createSlice({
  name: "socket",
  initialState,
  // Reducers: Functions we can call on the store
  reducers: {
    initSocket: (state) => {
      return;
    },
    connectionEstablished: (state) => {
      state.isConnected = true;
    },
    connectionLost: (state) => {
      state.isConnected = false;
    },
    joinRoom: (state, action: RoomAction) => {
      // After the required room is joined through middleware, we manage state here!
      let rooms = action.payload.rooms;
      state.rooms = state.rooms.concat(room);
      return;
    },
  },
});
 
// Don't have to define actions, they are automatically generated
export const { initSocket, connectionEstablished, connectionLost, joinRoom } =
  socketSlice.actions;
// Export the reducer for this slice
export default socketSlice.reducer;

Store Configuration: Integrating the Middleware

Finally, adding the middleware to the Redux store configuration completes the integration. We use the configureStore function from @reduxjs/toolkit package, and the socketMiddleware is incorporated into the middleware stack using the getDefaultMiddleware() function.

store.ts
import { configureStore, getDefaultMiddleware } from "@reduxjs/toolkit";
// Custom Middleware
import socketMiddleware from "./Middleware/socketMiddleware";
 
export const store = configureStore({
  reducer: {...},
  middleware(getDefaultMiddleware) {
    return getDefaultMiddleware().concat([socketMiddleware]);
  },
});

Conclusion

In conclusion, integrating Socket.io with Redux through middleware offers a robust and elegant solution for handling real-time communication in React. A singleton creation pattern and custom middleware provide a structured and scalable approach to managing these WebSocket connections and dispatching Redux actions seamlessly. Placing persistent connections in middleware also ensures a clear separation of concerns :)