Middleware is a great place to connect to WebSockets if you use Redux. This article explains what the Redux Middleware is and how to use it with WebSockets.
In this blog, we also talk a lot about various backend technologies such as Express. One of the concepts popular there is middleware. In redux, it serves a similar purpose. If you want to know more about Express, check out TypeScript Express tutorial #1. Middleware, routing, and controllers
Here, we use Redux Toolkit with TypeScript. To get the same starting template, run npx create-react-app my-app --template redux-typescript.
The idea behind Redux middleware
Middleware allows us to add logic between the moment the user dispatches the action and when it reaches the reducer. One of the most straightforward use-cases for the middleware is logging.
Something that might be a bit confusing is that in Redux, middleware uses currying. It is a technique that involves breaking down a function that takes multiple arguments into a chain of functions that take only one argument each. Dan Abramov justifies it by the fact that we might want to use the JavaScript closures to our advantage. While not everyone agrees with this design decision, it might not be worth creating a breaking change to fix it at this point.
To understand it, let’s create a simple example.
loggerMiddlewarre.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import { Middleware } from 'redux' const loggerMiddleware: Middleware = (store) => { return (next) => { return (action) => { console.log('dispatching', action); const result = next(action); console.log('next state', store.getState()); return result; } } } export default loggerMiddleware; |
We could write the above code in a shorter way, but let’s be very explicit for now.
When we add the above middleware to our store, it results in logging every action we dispatch.
Chaining middleware
The next argument is the most 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 are no middlewares left, it dispatches the action.
If we wouldn’t call the next function, the action wouldn’t be dispatched.
Let’s add another example from the official documentation to visualize it better.
crashMiddleware.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { Middleware } from 'redux' const crashMiddleware: Middleware = store => next => action => { console.log('crashMiddleware'); try { return next(action) } catch (error) { console.error('Caught an exception!', error) throw error; } } export default crashMiddleware; |
In this article, we use the Redux Toolkit. It adds some middleware to our store out of the box. To add new middleware while retaining the default ones, we can use the getDefaultMiddleware function as the documentation suggests.
store.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'; import counterReducer from '../features/counter/counterSlice'; import loggerMiddleware from './loggerMiddleware'; import crashMiddleware from './crashMiddleware'; export const store = configureStore({ reducer: { counter: counterReducer, }, middleware: (getDefaultMiddleware) => { return getDefaultMiddleware().concat([crashMiddleware, loggerMiddleware]) }, }); |
The counterReducer comes bundleded with the redux-typescript template mentioned at the top of this article.
Thanks to the fact that we’ve added crashMiddleware before the loggerMiddleware, it executes first.
If we wouldn’t call the next function in the crashMiddleware, the loggerMiddleware would never run and the action wouldn’t be dispatched.
Using WebSockets with middleware
In API with NestJS #26. Real-time chat with WebSockets, we’ve created a backend for a chat application using socket.io. We can see that there are a few events that can happen. Let’s create an enum that contains all of them:
Even though in this article we use socket.io, the could would be very similar with barebones WebSockets.
chatEvent.tsx
1 2 3 4 5 6 7 8 |
enum ChatEvent { SendMessage = 'send_message', RequestAllMessages = 'request_all_messages', SendAllMessages = 'send_all_messages', ReceiveMessage = 'receive_message' } export default ChatEvent; |
Establishing a connection
To establish a connection, we need to have the URL of our backend application. A good way to store it on the frontend is through the environment variables.
.env
1 |
REACT_APP_API_URL=http://localhost:3000 |
react-app-env.d.ts
1 2 3 4 5 6 7 |
/// <reference types="react-scripts" /> namespace NodeJS { interface ProcessEnv { REACT_APP_API_URL: string; } } |
Let’s also define the basics of our slice with the basic actions.
chatSlice.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
import { createSlice } from '@reduxjs/toolkit'; export interface ChatState { isEstablishingConnection: boolean; isConnected: boolean; } const initialState: ChatState = { isEstablishingConnection: false, isConnected: false }; const chatSlice = createSlice({ name: 'chat', initialState, reducers: { startConnecting: (state => { state.isEstablishingConnection = true; }), connectionEstablished: (state => { state.isConnected = true; state.isEstablishingConnection = true; }), }, }); export const chatActions = chatSlice.actions; export default chatSlice; |
The last part of establishing a connection is to create middleware. The API we’ve created in this article needs authentication through cookies. To handle it, we need the withCredentials argument.
chatMiddleware.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import { Middleware } from 'redux' import { io } from 'socket.io-client'; import { chatActions } from './chatSlice'; import ChatEvent from './chatEvent'; const chatMiddleware: Middleware = store => next => action => { if (!chatActions.startConnecting.match(action)) { return next(action); } const socket = io(process.env.REACT_APP_API_URL, { withCredentials: true, }); socket.on('connect', () => { store.dispatch(chatActions.connectionEstablished()); }) next(action); } export default chatMiddleware; |
Calling the chatActions.startConnecting.match function is how we can check if the action matches the one defined in the slice.
Our application establishes a connection through WebSockets when the user dispatches the startConnecting action.
Sending chat messages and receiving them
All of the messages from our backend have a particular data structure. So let’s create an interface that matches it.
chatMessage.tsx
1 2 3 4 5 6 7 8 9 |
interface ChatMessage { id: number; content: string; author: { email: string; } } export default ChatMessage; |
Now that we have the above interface, we can use it in our slice.
chatSlice.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import ChatMessage from "./chatMessage"; export interface ChatState { messages: ChatMessage[]; isEstablishingConnection: boolean; isConnected: boolean; } const initialState: ChatState = { messages: [], isEstablishingConnection: false, isConnected: false }; const chatSlice = createSlice({ name: 'chat', initialState, reducers: { startConnecting: (state => { state.isEstablishingConnection = true; }), connectionEstablished: (state => { state.isConnected = true; state.isEstablishingConnection = true; }), receiveAllMessages: ((state, action: PayloadAction<{ messages: ChatMessage[] }>) => { state.messages = action.payload.messages; }), receiveMessage: ((state, action: PayloadAction<{ message: ChatMessage }>) => { state.messages.push(action.payload.message); }), submitMessage: ((state, action: PayloadAction<{ content: string }>) => { return; }) }, }); export const chatActions = chatSlice.actions; export default chatSlice; |
The last part of sending and receiving messages is to listen to all of the events and dispatch them.
chatMiddleware.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
import { Middleware } from 'redux' import { io, Socket } from 'socket.io-client'; import { chatActions } from './chatSlice'; import ChatEvent from './chatEvent'; import ChatMessage from "./chatMessage"; const chatMiddleware: Middleware = store => { let socket: Socket; return next => action => { const isConnectionEstablished = socket && store.getState().chat.isConnected; if (chatActions.startConnecting.match(action)) { socket = io(process.env.REACT_APP_API_URL, { withCredentials: true, }); socket.on('connect', () => { store.dispatch(chatActions.connectionEstablished()); socket.emit(ChatEvent.RequestAllMessages); }) socket.on(ChatEvent.SendAllMessages, (messages: ChatMessage[]) => { store.dispatch(chatActions.receiveAllMessages({ messages })); }) socket.on(ChatEvent.ReceiveMessage, (message: ChatMessage) => { store.dispatch(chatActions.receiveMessage({ message })); }) } if (chatActions.submitMessage.match(action) && isConnectionEstablished) { socket.emit(ChatEvent.SendMessage, action.payload.content); } next(action); } } export default chatMiddleware; |
In the above code, we do the following:
- Emit the ChatEvent.RequestAllMessages event as soon as the application establishes the connection.
- Listen to the ChatEvent.SendAllMessages event that the backend emits in response to the ChatEvent.RequestAllMessages event.
- Emit the ChatEvent.SendMessage event when the user dispatches the chatActions.submitMessage action.
- Listen to the ChatEvent.ReceiveMessage event the backend emits when any of the users send a message.
Summary
In this article, we’ve explained what the middleware is in the context of Redux. When doing that, we’ve used Redux Toolkit and TypeScript. Since a middleware exists for the whole lifetime of our application, it is a good place to connect to the WebSocket. A middleware also has access to the current state and can dispatch actions. It can react to incoming messages and emit them when the user dispatches an action.
Thank you, this was really helpful
Bless you for writing this. As of Jan 2022, it was the only seriously helpful article I could find about writing websockets with Redux, TypeScript, and setting up the store with Redux Toolkit’s configureStore.
Thanks a lot for this nice article, as @George said it’s precious to find that kind of setup description, it’s working as good as described even if I don’t use socket.io but vanilla
I’ve searched far and wide, this is an awesome article, thanks for this. Nice, clean well documented code.
Hello, not sure if this thread is still alive and not hope for some response, but what about unsubscribing from events? should we or not?
help please!! Not working, startConnecting.match(action) always false, so socket not created