React-Redux and Websockets with socket.io React-Redux and Websockets with socket.io reactjs reactjs

React-Redux and Websockets with socket.io


Spoiler: I am currently developing what's going to be an open-source chat application.

You can do that better by separating actions from the middleware, and even the socket client from the middleware. Hence, resulting in something like this:

  • Types -> REQUEST, SUCCESS, FAILURE types for every request (not mandatory).
  • Reducer -> to store different states
  • Actions -> send actions to connect / disconnect / emit / listen.
  • Middleware -> to treat your actions, and pass or not the current action to the socket client
  • Client -> socket client (socket.io).

The code below is taken from the real app which is under development (sometimes slightly edited), and they are enough for the majority of situations, but certain stuff like the SocketClient might not be 100% complete.

Actions

You want actions to be as simple as possible, since they are often repeated work and you'll probably end up having lots of them.

export function send(chatId, content) {  const message = { chatId, content };  return {    type: 'socket',    types: [SEND, SEND_SUCCESS, SEND_FAIL],    promise: (socket) => socket.emit('SendMessage', message),  }}

Notice that socket is a parametrized function, this way we can share the same socket instance throughout the whole application and we don't have to worry about any import whatsoever (we'll show how to do this later).

Middleware (socketMiddleware.js):

We'll use a similar strategy as erikras/react-redux-universal-hot-example uses, though for socket instead of AJAX.

Our socket middleware will be responsible for processing only socket requests.

Middleware passes the action onto the socket client, and dispatches:

  • REQUEST (action types[0]): is requesting (action.type is sent to reducer).
  • SUCCESS (action types[1]): on request success (action.type and server response as action.result is sent to reducer).
  • FAILURE (action types[2]): on request failure (action.type and server response as action.error are sent to reducer).
export default function socketMiddleware(socket) {  // Socket param is the client. We'll show how to set this up later.  return ({dispatch, getState}) => next => action => {    if (typeof action === 'function') {      return action(dispatch, getState);    }    /*     * Socket middleware usage.     * promise: (socket) => socket.emit('MESSAGE', 'hello world!')     * type: always 'socket'     * types: [REQUEST, SUCCESS, FAILURE]     */    const { promise, type, types, ...rest } = action;    if (type !== 'socket' || !promise) {      // Move on! Not a socket request or a badly formed one.      return next(action);    }    const [REQUEST, SUCCESS, FAILURE] = types;    next({...rest, type: REQUEST});    return promise(socket)      .then((result) => {        return next({...rest, result, type: SUCCESS });      })      .catch((error) => {        return next({...rest, error, type: FAILURE });      })  };}

SocketClient.js

The only one that will ever load and manage the socket.io-client.

[optional] (see 1 below in the code). One very interesting feature about socket.io is the fact that you can have message acknowledgements, which would be the typical replies when doing an HTTP request. We can use them to verify that each request was correct. Note that in order to make use of this feature server socket.io commands do also have to have this latest acknowledgement parameter.

import io from 'socket.io-client';// Example conf. You can move this to your config file.const host = 'http://localhost:3000';const socketPath = '/api/socket.io';export default class socketAPI {  socket;  connect() {    this.socket = io.connect(host, { path: socketPath });    return new Promise((resolve, reject) => {      this.socket.on('connect', () => resolve());      this.socket.on('connect_error', (error) => reject(error));    });  }  disconnect() {    return new Promise((resolve) => {      this.socket.disconnect(() => {        this.socket = null;        resolve();      });    });  }  emit(event, data) {    return new Promise((resolve, reject) => {      if (!this.socket) return reject('No socket connection.');      return this.socket.emit(event, data, (response) => {        // Response is the optional callback that you can use with socket.io in every request. See 1 above.        if (response.error) {          console.error(response.error);          return reject(response.error);        }        return resolve();      });    });  }  on(event, fun) {    // No promise is needed here, but we're expecting one in the middleware.    return new Promise((resolve, reject) => {      if (!this.socket) return reject('No socket connection.');      this.socket.on(event, fun);      resolve();    });  }}

app.js

On our app start-up, we initialize the SocketClient and pass it to the store configuration.

const socketClient = new SocketClient();const store = configureStore(initialState, socketClient, apiClient);

configureStore.js

We add the socketMiddleware with our newly initialized SocketClient to the store middlewares (remember that parameter which we told you we would explain later?).

export default function configureStore(initialState, socketClient, apiClient) {const loggerMiddleware = createLogger();const middleware = [  ...  socketMiddleware(socketClient),  ...];

[Nothing special] Action types constants

Nothing special = what you would normally do.

const SEND = 'redux/message/SEND';const SEND_SUCCESS = 'redux/message/SEND_SUCCESS';const SEND_FAIL = 'redux/message/SEND_FAIL';

[Nothing special] Reducer

export default function reducer(state = {}, action = {}) {  switch(action.type) {    case SEND: {      return {        ...state,        isSending: true,      };    }    default: {      return state;    }  }}

It might look like a lot of work, but once you have set it up it is worth it. Your relevant code will be easier to read, debug and you will be less prone to make mistakes.

PS: You can follow this strategy with AJAX API calls as well.


For this purpose I used createAsyncThunk function from @reduxjs/toolkit. It automatically generates types like pending, fulfilled and rejected.

I used kind of the same socketService as @zurfyx in his answer.

The action looks like this:

const sendMessage = createAsyncThunk(  'game/send-message',  async function (text, { getState }) {    const roomToken = selectRoomToken(getState());    return await socketService.emit('send-message', { text, roomToken });  });

And the reducer looks like this:

const gameSlice = createSlice({  name: 'game',  initialState: { },  reducers: {},  extraReducers: {    [sendMessage.pending]: (state, action) => {      state.messages.push({        id: action.meta.requestId,        text: action.meta.arg,        my: true,      });    },    [sendMessage.rejected]: (state, action) => {      state.messages = state.messages.filter(        ms => ms.id !== action.meta.requestId      );    },  },});