DEV Community

Mat Sz
Mat Sz

Posted on

Tutorial: Using Redux and Redux-Saga to handle WebSocket messages.

Since I've discovered redux-saga I've found that it's perfect for asynchronous actions that affect the global state - and handling WebSocket messages is one of those things. The first time I've used this is in filedrop-web and it's been working well enough to make me consider writing a tutorial for it.

Disclaimer: I will be using TypeSocket, which is a library I've made. It makes certain WebSocket-related tasks easier without being too heavy (no special polyfills for platforms that don't support WS).

You can get TypeSocket from npm:

yarn add typesocket
# or
npm install typesocket
Enter fullscreen mode Exit fullscreen mode

The way my integration works is by creating a new Redux middleware that will contain the WebSocket handling code, will dispatch WebSocket messages and connection state updates and will react to incoming send message actions.

First, I have an ActionType enum, for all the available ActionTypes:

enum ActionType {
    WS_CONNECTED = 'WS_CONNECTED',
    WS_DISCONNECTED = 'WS_DISCONNECTED',
    WS_MESSAGE = 'WS_MESSAGE',
    WS_SEND_MESSAGE = 'WS_SEND_MESSAGE',
};
Enter fullscreen mode Exit fullscreen mode

Then I also define an interface for the message model (TypeSocket will reject all invalid JSON messages by default, but doesn't check if the message matches your type):

export interface MessageModel {
    type: string,
};
Enter fullscreen mode Exit fullscreen mode

This allows me to create an instance of TypeSocket:

import { TypeSocket } from 'typesocket';

const socket = new TypeSocket<MessageModel>(url);
Enter fullscreen mode Exit fullscreen mode

Which is what we'll be using within our middleware. url refers to the WebSocket URL.

Writing a Redux middleware around TypeSocket is really simple, first we create an empty middleware:

import { MiddlewareAPI } from 'redux';
import { TypeSocket } from 'typesocket';

import { ActionType } from './types/ActionType'; // Your enum with action types.
import { MessageModel } from './types/Models';   // Your message model.

export const socketMiddleware = (url: string) => {
    return (store: MiddlewareAPI<any, any>) => {
        // Here we will create a new socket...
        // ...and handle the socket events.

        return (next: (action: any) => void) => (action: any) => {
            // And here we'll handle WS_SEND_MESSAGE.

            return next(action);
        };
    };
};
Enter fullscreen mode Exit fullscreen mode

Now all that's left is adding our TypeSocket construction code into the middleware...

export const socketMiddleware = (url: string) => {
    return (store: MiddlewareAPI<any, any>) => {
        const socket = new TypeSocket<MessageModel>(url);

        // We still need the events here.

        return (next: (action: any) => void) => (action: any) => {
            // And here we'll handle WS_SEND_MESSAGE.

            return next(action);
        };
    };
};
Enter fullscreen mode Exit fullscreen mode

...and adding the event handling and message sending:

export const socketMiddleware = (url: string) => {
    return (store: MiddlewareAPI<any, any>) => {
        const socket = new TypeSocket<MessageModel>(url);

        // We dispatch the actions for further handling here:
        socket.on('connected', () => store.dispatch({ type: ActionType.WS_CONNECTED }));
        socket.on('disconnected', () => store.dispatch({ type: ActionType.WS_DISCONNECTED }));
        socket.on('message', (message) => store.dispatch({ type: ActionType.WS_MESSAGE, value: message }));
        socket.connect();

        return (next: (action: any) => void) => (action: any) => {
            // We're acting on an action with type of WS_SEND_MESSAGE.
            // Don't forget to check if the socket is in readyState == 1.
            // Other readyStates may result in an exception being thrown.
            if (action.type && action.type === ActionType.WS_SEND_MESSAGE && socket.readyState === 1) {
                socket.send(action.value);
            }

            return next(action);
        };
    };
};
Enter fullscreen mode Exit fullscreen mode

Now that this is taken care of, we need to add the middlewarae to our store. Let's first save the middleware in src/socketMiddleware.ts.

Then we can use it like this:

import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';

import { socketMiddleware } from './socketMiddleware';
import reducers, { StoreType } from './reducers';
import sagas from './sagas';

const sagaMiddleware = createSagaMiddleware();
const store = createStore(
    reducers,
    applyMiddleware(socketMiddleware('ws://localhost:5000/'), sagaMiddleware),
);

sagaMiddleware.run(sagas, store.dispatch);
Enter fullscreen mode Exit fullscreen mode

I'm assuming that there are reducers available from ./reducers and sagas (for Redux Saga) in ./sagas.

Now, let's start using Redux Saga to handle our messages. This is pretty simple and comes down to utilizing Redux-Saga's takeEvery:

function* message(action: ActionModel) {
    const msg: MessageModel = action.value as MessageModel;

    // Now we can act on incoming messages
    switch (msg.type) {
        case MessageType.WELCOME:
            yield put({ type: ActionType.WELCOME, value: 'Hello world!' });
            break;
    }
}

export default function* root(dispatch: (action: any) => void) {
    yield takeEvery(ActionType.WS_MESSAGE, message);
}
Enter fullscreen mode Exit fullscreen mode

Sending messages with our setup is also that easy, you will just have to dispatch the message like so:

dispatch({ type: Action.WS_SEND_MESSAGE, value: message });
Enter fullscreen mode Exit fullscreen mode

I prefer using this method over using any other Redux WebSocket libraries because of the flexibility I get when it comes to handling actions inside of the middleware, there's a lot of things you can customize. TypeSocket can be replaced for a pure WebSocket as well, if necessary.

Top comments (1)

Collapse
 
dzintars profile image
Dzintars Klavins

Have you tried

export const selectApplication = (id: string): ApplicationActionTypes => ({
  type: ApplicationTypes.SELECT,
  id,
  meta: { websocket: true },
})
Enter fullscreen mode Exit fullscreen mode


?
I mean - to meta-tagg messages so you can intercept only tagged messages at middleware level and send them over wss?
Also, how do you sync message signatures with the back-end? I have an rough idea to try generate TS message typings from protobufs alongside with swagger docs. But i didn't tried that yet.