πŸ“œ ⬆️ ⬇️

Shopping with full-stack redux

Hello! In this article, I want to use a simple example to tell how to synchronize the status of redux applications between several clients and a server, which can be useful when developing realtime applications.


Redux


application


As an example, we will develop a shopping list, with the ability to change positions from any device in real time, according to the following requirements:



In order to do the minimum effort, we will not do UI to access all the lists, but simply distinguish them by identifier in the URL.


It can be noted that the application in this form from the point of view of client functionality and UI will differ little from the famous todo list . Therefore, the article will focus more on client-server interaction, saving and processing state on the server.


I also have plans to write the following article, where, using the example of the same application, we will look at how to save the redux state in DynamoDB and roll out the AWS-packed application in Docker.


Getting to the development


To create a development environment, we will use the wonderful tool create-react-app . It’s quite easy to create prototypes with it: it will prepare everything you need for productive development: webpack with hot-reload, initial set of files, jest tests. It would be possible to customize it all by yourself for greater control over the assembly process, but in this application it is not critical.


We create a name for our application and create it by passing as an argument to create-react-app:


create-react-app deal-on-meal cd deal-on-meal 

Project structure


create-react-app created some project structure for us, but in fact for its correct operation it is only necessary that the ./src/index.js file ./src/index.js the entry point. Since our project implies the use of both the client and the server, we will change the initial structure to the following:


 src └──client └──modules └──components └──index.js └──create-store.js └──socket-client.js └──action-emitter.js └──constants └──socket-endpoint-port.js └──server └──modules └──store └──utils └──bootstrap.js └──connection-handler.js └──server.js └──index.js └── registerServiceWorker.js 

Add the package.json command to start the node ./src/server.js server


Client-server interaction


Application state redux stores in the so-called store as a javascript object of any type. At the same time, any change of state must be carried out by a reducer β€” a pure function, the input of which is the current state and action (also a javascript object). It returns a new state with changes.


We can use our client logic for the shopping list, which the reducer implements, both on the browser side and in the node.js environment. Thus, we will be able to store the state of the list independently of the client and save it in the database.


To work with the server, we will use the socket.io library, which has already become the standard for working with socket.io . For each list, we will create our own room and send each action to those users who are in the same room. In addition, for each room on the server we will store our store with the status for this list.


Synchronization of clients with the server will occur as follows:



That is, every time an action occurs, then:



Mostly in the redux-world, interaction with the server takes place via http, through libraries like redux-thunk or redux-saga , which allow you to add some asynchrony and side effects to the synchronous, pure world redux. Although we do not need this kind of connection with the server, we will also use redux-saga on the client, but only for one task: redirecting to the newly created list in case there is no identifier in the URL.


We write client code


I will not focus on the initialization required for redux to work, this is well described in redux official documentation, let me just say that we need to register two middleware: from the mentioned package redux-saga and our emitterMiddleware . The first is needed for the redirect, as already mentioned, and the last we will write to synchronize actions with the server via socket.io-client .


State synchronization between clients and server


Create a ./src/client/action-emitter.js file in which the implementation of the mentioned emitterMiddleware will be:


 export const syncSocketClientWithStore = (socket, store) => { socket.on('action', action => store.dispatch({ ...action, emitterExternal: true })); }; export const createEmitterMiddleware = socket => store => next => action => { if(!action.emitterExternal) { socket.emit('action', action); } return next(action); }; 


Getting the initial state of the list


As I have already mentioned, we will use redux-saga on the client so that when you first redux-saga client's redux-saga to the corresponding list that could have been created just now. In a ./src/client/modules/products-list/saga/index.js manner, in ./src/client/modules/products-list/saga/index.js we ./src/client/modules/products-list/saga/index.js describe a saga that responds to the list of products and the room in which the client is located:


 import { call, takeLatest } from 'redux-saga/effects' import actionTypes from '../action-types'; export function* onSuccessGenerator(action) { yield call(window.history.replaceState.bind(window.history), {}, '', `/${action.roomId}`); } export default function* () { yield takeLatest(actionTypes.FETCH_PRODUCTS_SUCCESS, onSuccessGenerator); } 

Server


The entry point for the server will be added to the scripts in package.json ./src/server.js :


 require('babel-register')({ presets: ['env', 'react'], plugins: ['transform-object-rest-spread', 'transform-regenerator'] }); require('babel-polyfill'); const port = require('./constants/socket-endpoint-port').default; const clientReducer = require('./client').rootReducer; require('./server/bootstrap').start({ clientReducer, port }); 

It is worth noting that when starting our server, a client reducer is passed to it: this is necessary so that the server can also maintain the current status of the lists, receiving only actions, and not the entire state. ./src/server/bootstrap.js look at ./src/server/bootstrap.js :


 import createSocketServer from 'socket.io'; import connectionHandler from './connection-handler'; import createStore from './store'; export const start = ({ clientReducer, port }) => { const socketServer = createSocketServer(port); const store = createStore({ socketNamespace: socketServer.of('/'), clientReducer }); socketServer.on('connection', connectionHandler(store)); console.log('listening on:', port); } 

Server logic


Let us proceed to the logic specific to our server and describe the actions that it should support:



I also propose to describe all these actions with the help of redux and for this purpose make the module ./src/server/modules/room-service , containing the corresponding saga and reducer. In the same place we will make the elementary storage for our room stores ./src/server/modules/room-service/data/in-memory.js :


 export default class InMemoryStorage { constructor() { this.innerStorage = {}; } getRoom(roomId) { return this.innerStorage[roomId]; } saveRoom(roomId, state) { this.innerStorage[roomId] = state; } deleteRoom(roomId) { delete this.innerStorage[roomId]; } } 

Synchronization of server and client states


At a socket server event, we will simply make dispatch with the appropriate action from the room-service module on the server store. We describe this in ./src/server/connection-handler.js :


 import { actions as roomActions } from './modules/room-service'; import templateParseUrl from './utils/template-parse-url'; const getRoomId = socket => templateParseUrl('/list/{roomId}', socket.handshake.headers.referer).roomId.toString() || socket.id.toString().slice(1, 6); export default store => socket => { const roomId = getRoomId(socket); store.dispatch(roomActions.userJoin({ roomId, socketId: socket.id })); socket.on('action', action => store.dispatch(roomActions.dispatchClientAction({ roomId, clientAction: action, socketId: socket.id }))); socket.on('disconnect', () => store.dispatch(roomActions.userLeft({ roomId }))); }; 

Let us leave the processing of userJoin and userLeft to the conscience of an inquisitive reader, who was not too lazy to look into the repository, but to look at how dispatchClientAction handled. As we remember, it is necessary to do two actions:



Responsible for the first generator ./src/server/modules/room-service/saga/dispatch-to-room.js :


 import { call, put } from 'redux-saga/effects'; import actions from '../actions'; import storage from '../data'; const getRoom = storage.getRoom.bind(storage); export default function* ({ socketServer, clientReducer }, action) { const storage = yield call(getRoom, action.roomId); yield call(storage.store.dispatch.bind(storage.store), action.clientAction); yield put(actions.emitClientAction({ roomId: action.roomId, clientAction: action.clientAction, socketId: action.socketId })); }; 

He also puts the following action module room-service - emitClientAction , which responds ./src/server/modules/room-service/saga/emit-action.js :


 import { call, select } from 'redux-saga/effects'; export default function* ({ socketNamespace }, action) { const socket = socketNamespace.connected[action.socketId]; const roomEmitter = yield call(socket.to.bind(socket), action.roomId); yield call(roomEmitter.emit.bind(roomEmitter), 'action', action.clientAction); }; 

This is the simple way that action games fall on the rest of the clients in the room. In my opinion, the simplicity of full-stack redux lies in the simplicity, as well as the power of re-using client logic to reproduce state on the server and other clients.


Conclusion


At least a little messy, but my story comes to an end. I did not focus on things that already have a lot of articles and lessons (in any case, the full code of the application can be viewed in the repository ), but stopped on what information, in my opinion, less. So if you have any questions - please in the comments.


')

Source: https://habr.com/ru/post/338142/


All Articles