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.