📜 ⬆️ ⬇️

Architecture of modular React + Redux applications 2. Core

In the first part, I paid attention only to the general concept: reducers, components and actions often change at the same time, rather than separately, therefore it is more expedient to group them and by modules, rather than by separate folders actions , components , reducers . Also the following requirements were made to the modules:

  1. be independent of each other
  2. interact with the application through the kernel API

In this part, I will talk about the structure of the core, suitable for developing data-driven systems.
Let's start with the definition of the module. Working with a simple object is not very convenient. Add a bit of OOP:

const _base = Symbol('base') const _ref = Symbol('ref') class ModuleBase{ constructor(base){ this[_base] = base this[_ref] = getRef(this) } /** * unique module id * @returns {string} */ get id(){ return this.constructor.name } /** * full module ref including all parents * @returns {string} */ get ref(){ return this[_ref] } /** * module title in navigation * @returns {string} */ get title(){ return this.id } /** * module group in navigation * @returns {string} */ get group(){ return null } /** * react component * @returns {function} */ get component() { return null } /** * router route * @return {object} */ get route(){ return getRoute(this) } /** * router path * @return {string} */ get path(){ return this.id } /** * children modules * @return {Array} */ get children(){ return [] } /** * @type {function} */ reduce //.... } 
The code above uses symbols to implement encapsulation.
Now the declaration of the module is more familiar - it is necessary to inherit the ModuleBase class, redefine the necessary getters, and optionally add a define to reduce , which will be the reducer.
')
Last time, we limited the nesting of modules to the second level. In real applications, this is not enough. In addition, we had to choose between the reducer of the parent module and the combination of the reducer subsidiaries. This "breaks" the composition.

For example, if we want to create a standard CRUD above an entity in a database, it is logical to organize modules like this:

 /SomeEntity /components /Master.js /children /index.js /create.js /update.js /index.js 

We assume that the standard form component is used for create and update , and the standard Grid component from the system kernel is used for data output; therefore, it suffices to define only the modules for these operations.

The parent module is responsible for the output of the layout, the “create”, “back to the list” links and messages about the success or failure of requests to the server. Index - for filtering, pagination and links. Create and Update take the form to create and edit.

Thus, the reducer of the parent module must have access to the entire subgraph of the module status, and the children - each to its part. Implement two layout functions.

For routes


 const getRoute = module => { const route = { path: module.path, title: module.title, component: module.component } const children = module.children if(children) { ModuleBase.check(children) const index = children.filter(x => x.id.endsWith(INDEX)) if(index.length > 0){ // share title with parent module route.indexRoute = { component: index[0].component } } route.childRoutes = module.children .filter(x => !x.id.endsWith(INDEX)) .map(getRoute) } return route } 

And for reusers


 class ModuleBase{ //.... combineReducers(){ const childrenMap = {} let children = Array.isArray(this.children) ? this.children : [] ModuleBase.check(children) const withReducers = children.filter(x => typeof(x.reduce) === 'function' || x.children.length > 0) for (let i = 0; i < withReducers.length; i++) { childrenMap[children[i].id] = children[i] } if(withReducers.length == 0){ return reducerOrDefault(this.reduce) } const reducers = {} for(let i in childrenMap){ reducers[i] = childrenMap[i].combineReducers() } const parent = this const reducer = typeof(this.reduce) === 'function' ? (state, action) => { if(!state){ state = parent.initialState } const nextState = parent.reduce(state, action) if(typeof(nextState) !== 'object'){ throw Error(parent.id + '.reduce returned wrong value. Reducers must return plain objects') } for(let i in childrenMap){ if(!nextState[i]){ nextState[i] = childrenMap[i].initialState } nextState[i] = {...reducers[i](nextState[i], action)} if(typeof(nextState[i]) !== 'object'){ throw Error(childrenMap[i].id + '.reduce returned wrong value. Reducers must return plain objects') } } return {...nextState} } : combineReducers(reducers) return reducer } 

This is not the most effective implementation of such a reducer. Unfortunately, even she took me quite a lot of time. I would be grateful if someone in the comments tells you how to do better.

Matching Routes and State


This modular system implementation relies on the one-to-one state and route correspondence, with a few exceptions:

  1. /Update is replaced by /:id
  2. /Index omitted ( indexRoute used)
  3. There is no route for Delete . Removal is done from the Index module.

The path method can be redefined and then the route will be distinguished from the module name. You can construct a chain of modules of any nesting. Moreover, if your application has only one root route / , then it is advisable to make the App module and put all the others into it in order to use one approach everywhere.
This will allow the App (if needed) to handle any application events and modify the state of any child module. Perhaps it is too cool for anyone, even the coolest reducer. I do not recommend overriding reduce at all for the parent module of the application. However, such a reducer can be useful for system operations.

With the routing done, it remains to " connect " the components to the state. Since the receivers are arranged recursively in accordance with the embedding of the child modules, we will also connect. Everything is simple here. The implementation of mapDispatchToProps will mapDispatchToProps discussed below.

Kernel components


So, ModuleBase is the first and integral part of the kernel. Without it, your code to the application does not pick up. ModuleBase provides the following API:

  1. Register component in the router
  2. Registration of the module's reducer
  3. Connect components to redux state

Not bad, but not enough. CRUD should be easy to do. Add a DataGridModuleBase and FormModuleBase . Until now we have not specified which components are used in the modules.

Components and containers


Containers are one of the most common patterns in React. In short, the difference between components and containers is as follows:

  1. Components (or presentation components) do not contain external dependencies and logic
  2. Containers (as the name implies) wrap the components, realizing the binding between the outside world and the components

Such an organization improves code reuse, helps in sharing work among different specialists and simplifies testing.

The connect (react-redux) function is essentially a container factory.

To develop a DataGridModule we need:

  1. DataGrid component
  2. its DataGridContainer container
  3. reducer for communication between container and application state in redux

I omit the implementation of the presentation component. To connect to the state, we have the ModuleBase.connect function. It remains to receive data from the server. You can create a new class for each grid and override componentDidMount or other methods of the component life cycle. The approach, in general, is working, but has two significant drawbacks:

  1. a huge amount of boilerplate and copy-paste. A copy-paste, as you know, always leads to errors
  2. low development speed of modules: the kernel does not yet provide any API to speed up development, this is wrong

Impurities (mixin)


I recommend to familiarize with the documentation of react concerning impurity. Use them with caution, otherwise you can step on a variety of rakes.

Extend the layout of components and containers using mixins. class and extends are first class objects in ES6. In other words, the const Enhanced = superclass => class extends superclass valid. This is possible thanks to the JavaScript prototype inheritance system.

Add the mix function and the Preloader and ServerData impurities to the ServerData :

 const Preloader = Component => class extends Component { render() { const propsToCheck = subset(this.props, this.constructor.initialState) let isInitialized = true let isFetching = false for(let i in propsToCheck){ if(typeof(propsToCheck[i][IS_FETCHING]) === 'boolean'){ if(!isFetching && propsToCheck[i][IS_FETCHING]){ isFetching = true } // if something except "isFetching" presents it's initialized if(isInitialized && Object.keys(propsToCheck[i]).length === 1){ isInitialized = false } } } return isInitialized ? (<Dimmer.Dimmable dimmed={isFetching}> <Dimmer active={isFetching} inverted> <Loader /> </Dimmer> {super.render()} </Dimmer.Dimmable>) : (<Dimmer.Dimmable dimmed={true}> <Dimmer active={true} inverted> <Loader /> </Dimmer> <div style={divStyle}></div> </Dimmer.Dimmable>) } } const ServerData = superclass => class extends mix(superclass).with(Preloader) { componentDidMount() { this.props.queryFor( this.props.params, subset(this.props, this.constructor.initialState)) } 

The first one checks all keys in the stack and if it finds at least one with a certain property isFetching: true displays the dimmer on top of the component. If, apart from isFetching , there are no properties in the object, we consider that they should come from the server and do not display the component at all (we assume it is not initialized).

Mix ServerData automatically mixes the preloader and overrides the componentDidMount .

queryFor


Let us consider in more detail the implementation of queryFor. It was passed to Module.connect via mapDispatchToProps .

 export const queryFactory = dispatch => { if(typeof (dispatch) != 'function'){ throw new Error('dispatch is not a function') } return (moduleId, url, params = undefined) => { dispatch({ type: combinePath(moduleId, GET), params }) return new Promise(resolve => { dispatch(function () { get(url, params).then(response => { const error = 'ok' in response && !response.ok const data = error ? {ok: response.ok, status: response.status} : response dispatch({ type: combinePath(moduleId, GET + (error ? FAILED : SUCCEEDED)), ...data }) resolve(data) }) }) }) } } export const queryAll = (dispatch, moduleRef, params, ...keys) => { const query = queryFactory(dispatch) if(!keys.length){ throw new Error('keys array must be not empty') } const action = combinePath(moduleRef, keys[0]) let promise = query(action, fixPath(action), params) for(let i = 1; i < keys.length; i++){ promise.then(() => { let act = combinePath(moduleRef, keys[i]) query(act, fixPath(act), params) }) } } export const queryFor = (dispatch, moduleRef, params, state) => { const keys = [] for (let i in state) { if (state[i].isFetching !== undefined) { keys.push(toUpperCamelCase(i)) } } return queryAll(dispatch, moduleRef, params, ...keys) 

Using queryFactory we create a query function, which makes a request to the server, dispatches the corresponding events in the store and returns a promise , so that we can build a query chain of the function in the queryAll , which the queryFor function, which is oriented to the presence of the house that Jack built .

We add an “obhoshchalka” for a state that requires server data:

 ServerData.fromServer = (initialState, ...keys) => { for(let i = 0; i < keys.length; i++){ initialState[keys[i]].isFetching = false } return initialState } 

Now it’s enough to know the rules of using mixin to make any component working with client data on the server one. It is enough to properly configure initialState and connect mixin.

It remains to process the events of the start of receiving data, successful receipt and errors and change the state of the container accordingly. To do this, we add a reducer in the module.

ServerData.reducerFor


 ServerData.reducerFor = (moduleRef, initialState, next = null, method = GET) => { if(!moduleRef){ throw Error('You must provide valid module name') } if(!initialState){ throw Error('You must provide valid initialState') } const reducer = {} for (let i in initialState) { reducer[i] = hasFetching(initialState, i) ? ServerData.serverRequestReducerFactory(combinePath(moduleRef, i), initialState[i], next, method) : passThrough(initialState[i]) } if(Object.keys(reducer) < 1){ throw Error('No "isFetching" found. Cannot build reducer') } const combined = combineReducers(reducer) return combined } export default class DataGridModuleBase extends ModuleBase { constructor(base){ super(base) // Create is required due to children module this.reduce = ServerData.reducerFor(this.ref, DataGridContainer.initialState) } get component () { return this.connect(DataGridContainer) } } 

Add a module with a grid to the application


 export default class SomeEntityGrid extends DataGridModuleBase { } //.. const _children= Symbol('children') export default class App extends ModuleBase{ constructor(base){ super(base) this[_children] = [new SomeEntityGrid(this)] } get path (){ return '/' } get component () { return AppComponent } get children(){ return this[_children] } 

If you have read to the end, you can implement FromModuleBase by analogy.

The final structure of the nucleus


 /core /ModuleBase.js /api.js /components /containers /modules /mixins 

  1. Base modules contain reusable logic and sets of standard components that are often used together (for example, CRUD ).
  2. The components and containers folders contain frequently used components and containers, respectively.
  3. With the help of impurities, components and containers can be arranged: a grid with server data, a grid with inline input, a grid with server data and inline input, etc.
  4. api.js contains functions for working with the server: fetch, get, post, put, del, ...

Division of responsibility


  1. Modules: routing, container creation, transfer of necessary functions to the container, reducer for the component, provision of meta-information.
  2. Components: reusable UI parts. Go well with BEM . Can be developed independently of the main application by a separate command.
  3. Containers: displaying the status of an application and API set on presentation components.
  4. Additional middleware: not used. Instead, only redux-thunk . Additional middleware is not used because it complicates the system. Using redux-saga makes the learning curve much worse and increases the size of the bundle, so thunk is preferred.

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


All Articles