Usando Redux en el servidor con Socket.io

Redux fue hecho para controlar el estado de la UI de una aplicación. Resulta que mientras podamos tener una única instancia del store Redux también puede servir en Backend, por ejemplo en aplicaciones Real-time usando Socket.io, donde el estado de la aplicación se mantendría, e incluso compartiría entre varios usuarios conectados.

Instalación de dependencias

npm i -S redux socket.io socket.io-client redux-duck

Creando nuestro Store y Reducers

Lo primero que vamos a hacer es crear los reducers, imaginemos que tenemos una aplicación de chat, el estado de nuestra aplicación podría ser algo así:

type user = {
id: number,
username: string,
};
type message = {
id: number,
author: number,
content: string,
};
type state = {
users: Map<user>,
messages: List<message>,
};

Vamos entonces a crear los ducks de nuestra aplicación.

import { createDuck } from 'redux-duck';
import { List as list } from 'immutable';
const duck = createDuck('messages', 'chat');
const ADD = duck.defineType('ADD');
const REMOVE = duck.defineType('REMOVE');
export const addMessage = duck.createAction(ADD);
export const removeMessage = duck.createAction(REMOVE);
export default duck.createReducer({
[ADD]: (state, { payload = {} }) => {
return state.push(map(payload));
},
[REMOVE]: (state, { payload = {} }) => {
return state
.filterNot(message => message.get('id') === payload.id);
},
}, list());

Ese va a ser nuestro duck para los mensajes del chat.

import { createDuck } from 'redux-duck';
import { Map as map } from 'immutable';
const duck = createDuck('user', 'chat');
const ADD = duck.defineType('ADD');
export const addUser = duck.createAction(ADD);
export default duck.createReducer({
[ADD]: (state, { payload = {} }) => {
return state.set(payload.id + '', map(payload));
},
}, map());

Y ese va a ser nuestro duck para manejar los usuarios. Como vemos nuestros ducks son muy simples, solo podemos agregar usuarios y en cuanto a los mensajes solo podemos agregar y quitar mensajes.

Ahora para crear nuestro reducer nos traemos los de nuestros ducks:

import { combineReducers } from 'redux';
import messages from './ducks/messages';
import users from './ducks/users';
export default combineReducers({
messages,
users,
});

Y con eso ya tenemos nuestro reducer listo. Ahora vamos a crear nuestro servidor de WebSockets.

Servidor de WebSockets

Una vez que tenemos nuestro reducer y nuestros creadores de acciones vamos a crear un servidor de WebSockets usando socket.io.

import Server from 'socket.io';
export default function startServer(store) {
// creamos el servidor escuchando el puerto que
// recibimos como variable de entorno
const io = new Server().attach(process.env.PORT);
  // nos suscribimos a los cambios del store y
// mandamos el estado actual en cada cambio por
// el canal 'state' de socket.io
store.subscribe(
() => io.emit('state', JSON.stringify(store.getState()))
);
  // cuando el usuario se conecte
io.on('connection', socket => {
// le emitimos el estado actual
socket.emit('state', JSON.stringify(store.getState()))
    // y escuchamos cada acción que mande
socket.on('action', store.dispatch.bind(store));
});
  return io;
}

Luego vamos a iniciar nuestro servidor y pasarle nuestro store.

import { createStore } from 'redux';
import reducer from './reducer';
import startServer from './start-server';
const store = createStore(reducer);
const server = startServer(store);

Ahora solo nos queda levantar nuestro servidor usando Node.js

npm start

Con esto ya tenemos un servidor de WebSockets cuyo estado se guarda en Redux. Ahora vemos como sería un cliente sencillo.

Cliente web

Primero vamos a crear un duck para nuestra aplicación.

import { createDuck } from 'redux-duck';
const duck = createDuck('client', 'chat');
const UPDATE_STATE = duck.defineType('UPDATE_STATE');
export const updateState = duck.createAction(UPDATE_STATE);
export default duck.createReducer({
[UPDATE_STATE]: (state, { payload = {} }) => {
return payload;
},
], {});

Ese super simple duck va a ser toda nuestra aplicación de Redux en el Frontend. Ahora vamos a iniciar nuestro Store y conectarnos al servidor de sockets.

import io from 'socket.io-client';
import { createStore } from 'redux';
import reducer, { updateState } from './duck/client';
// nos conectamos al servidor
const socket = io('http://localhost');
// al conectarnos
socket.on('connect', initialState => {
// recibimos el estado inicial y creamos el store
const store = createStore(reducer, initialState);
  // cuando el servidor nos mande una actualización
// despachamos la acción updateState
socket.on('state', nextState => {
store.dispatch(updateState(nextState);
});
});

Con eso ahora el estado de nuestra aplicación nos va a llegar por WebSockets y con eso vamos a iniciar nuestro Store, además en cada cambio que se realice en el servidor vamos a recibir todo el nuevo estado y vamos a actualizar el Store.

Por último en nuestra aplicación nos tocaría que cada acción despachada se envíe por WebSockets en el canal action de forma que llegue al servidor y se actualice el Store ahí guardado.

Una última idea podría ser implementar un middleware en nuestro Store del lado del servidor que se encargue de guardar en una base de datos el payload de cada acción para que no se pierdan datos si se cae el servidor.

Conclusión

Este ejemplo es super simple, y no recomiendo que se use tal cual en producción. En una aplicación de verdad donde queramos replicar el Store en el servidor lo ideal sería que nuestro servidor de WebSockets nos mande el estado inicial del Store al conectarnos y luego cada acción que se realice, las cuales deberían crearse en el cliente que genera la acción y mandarlas por socket.io.

De esta forma en el cliente solo actualicemos lo necesario y no todo el estado de nuestra aplicación de golpe, esto reduciría la cantidad de datos enviados por WebSockets (menor consumo de datos en móviles), de la misma forma el servidor debería despachar a su Store propio la acción, así el estado ahí almacenado se mantendría actualizado para la próxima persona en conectarse y mientras cada cliente tiene su propio Store y se encarga de actualizarse.

Un problema que podría llegar a ocurrir de usar esta forma es que si un cliente se desconecta no le llegarían algunas acciones, lo cual puede significar una perdida de datos y en dejar de estar sincronizado con el Store. Esto se puede solucionar ya sea enviando todo el estado cada X tiempo o crear alguna especie de cola de acciones cuando el servidor detecte una desconexión del cliente.