📜 ⬆️ ⬇️

Architecture of modular React + Redux applications



Most developers start exploring Redux with the Todo List Project . This application has the following structure:

actions/ todos.js components/ todos/ TodoItem.js ... constants/ actionTypes.js reducers/ todos.js index.js rootReducer.js 

At first glance, such an organization of code seems logical, because it resembles the standard conventions of many backend MVC frameworks:
')
 app/ controllers/ models/ views/ 

In fact, this is a bad choice for both MVC and React + Redux applications for the following reasons:

  1. As the application grows, it becomes extremely difficult to monitor the relationship between components, actions and reductors.
  2. If you change the action or component, you are likely to make changes to the reducer. If the number of files is large, scrolling up / down the IDE is not convenient
  3. Such a structure connives copy-paste in rediresers.

Not surprisingly, many authors ( one , two , three ) advise to structure the application by "functionality" ( by feature ).

We came to the same conclusion in backend development quite a long time ago, so we are doing the same in the frontend. In Russian there is no suitable translation for the word feature as a unit of functionality. Instead, we use the word "module". In ES6, the term “module” has a different meaning. In order not to confuse them with each other in case of ambiguity, the phrase “application module” can be used. There were no difficulties in everyday work, besides this, the term “module” is well understood and suitable for communication with business users.

Modular structure

The module is a functionally complete fragment of the program.

Modular programming is the organization of a program as a set of small independent blocks, called modules, whose structure and behavior obey certain rules.

A modular application in my understanding should meet the following requirements:

  1. All module code is located in one folder. To completely remove a module from the program, simply delete the corresponding folder. Removing a module does not violate the functionality of other modules, but deprives the application of some functionality.
  2. Modules are independent of each other. Modification of any module does not affect the operation of other modules. The modules are allowed to depend on the “core” of the system.
  3. The system kernel contains a public API that provides I / O modules for the modules and a set of components for creating a UI.

We get the following application structure:

 app/ modules/ Module1/ … index.js Module2/ … index.js … index.js core/ … index.js routes.js store.js 

At the entry point we place the AppContainer , necessary for react-hot-reload , with the Root component attached. Root contains only the Provider , providing communication with redux and react-router , which defines the entry point into the application using indexRoute . The component can be transferred to the npm-package and connected in any application, since it only initializes the infrastructure and does not contain the logic of the object model.

index.js


 import 'isomorphic-fetch' import './styles/app.sass' import React from 'react' import ReactDOM from 'react-dom' import { AppContainer } from 'react-hot-loader' import browserHistory from './core/history' import Root from './core/containers/Root' import store from './store'; import routes from './routes'; ReactDOM.render( <AppContainer> <Root store={store} history={browserHistory} routes={routes}/> </AppContainer>, document.getElementById('root')); 

Root.js


 import React from 'react' import PropTypes from 'prop-types' import {Provider} from "react-redux" import {Router} from "react-router" const Root = props => ( <Provider store={props.store}> <Router history={props.history} routes={props.routes} /> </Provider>) Root.propTypes = { history: PropTypes.object.isRequired, routes: PropTypes.array.isRequired, store: PropTypes.object.isRequired } export default Root 

So far, everything is quite simple. It remains for us to connect the modular system to the state (store) and set up routing.

defineModule


Let's write a small function:

 export const defineModule = ( title, path, component, reducer = (state = {}) => state, onEnter = null) => { return {title, path, component, reducer, onEnter} } 

Let's create a module in the user's personal account in the modules folder.

 modules/ Profile/ Profile.js index.js 

Profile / Profile.js


 import React from 'react' import PropTypes from 'prop-types' const Profile = props => (<h2>, {props.name}</h2>) Profile.propTypes = { name: PropTypes.string.isRequired } export default Profile 

Profile / index.js


 const SET_NAME = 'Profile/SetName' const reducer (state = {name: ''}, action) => { switch(action.type){ case SET_NAME: {…state, name: action.name} } } export default defineModule(' ', '/profile, Profile') 

And register the module in the modules / index.js file

 import Profile from './Profile' export default { Profile } 

This step can be avoided, but for clarity, we will leave the manual initialization of the modular structure. Two lines of import / export is not so difficult to write.

I use CamelCase and / for better readability in action titles. In order to make it easier to collect, you can use this function:

 export const combineName = (...parts) => parts .filter(x => x && toLowerCamelCase(x) != DATA) .map(x => toUpperCamelCase(x)) .reduce((c,n) => c ? c + '/' + n : n) const Module = 'Profile' const SET_NAME = combineName(Module, 'SetName') 

It remains to connect the personal account to the router and insert the module into the layout. Everything is simple with the layout. We create core/components/App.js Note that the same array is transferred to the Navigation component as to the router to avoid duplication.

 import React from 'react' import PropTypes from 'prop-types' import Navigation from './Navigation' const App = props => ( <div> <h1>{props.title}</h1> <Navigation routes={props.routes}/> {props.children} </div>) App.propTypes = { title: PropTypes.string.isRequired, routes: PropTypes.array.isRequired } export default App 

Router


And with a router it will be a little more difficult. In general, it should be possible to associate more than one URL with a module. For example, /profile contains basic /profile information, and /profile/transactions , a list of user transactions. Suppose We want to always display the user name in your personal account, and below display the component with two tabs: “general information” and “transactions”.

Then, the logical structure of the routes will be:

  <Router> <Route path="/profile" component={Profile}> <Route path="/info" component={Info}/> <Route path="/transactions" component={Transaction}/> </ Route > </Router> 

The Profile component will display the user name and tabs, and Info and Transactions will display the profile details and the list of transactions, respectively. But it is also necessary to support the option when the module components do not need an additional grouping module (for example, the order list and the order viewing window are independent pages).

We introduce an agreement


From a module, you can export an object as a structure as returned from the defineModule function or an array of such objects. All components will be added to the list of routes without additional nesting.

A module may contain the children key, which contains a structure similar to the modules/index.js . In this case, one of them should be called Index . It will be used as an IndexRoute . Then we get the structure corresponding to the "personal account".

We use the monoidal nature of the list and obtain a flat array of modules, taking into account the ability to export an array or object.

 export const flatModules = modules => Object.keys(modules) .map(x => { const res = Array.isArray(modules[x]) ? modules[x] : [modules[x]] res.forEach(y => y[MODULE] = x) return res }) .reduce((c,n) => c.concat(n)) 

In Router, you can transfer not only Route components, but also just an array with ordinary objects, which we will use.

 export const getRoutes = (modules, store, App, Home, title = '') => [ { path: '/', title: title, component: App, indexRoute: { component: Home }, childRoutes: flatModules(modules) .map(x => { if (!x.component) { throw new Error('Component for module ' + x + ' is not defined') } const route = { path: x.path, title: x.title, component: x.component, onEnter: x.onEnter ? routeParams => { x.onEnter(routeParams, store.dispatch) } : null } if(x.children){ if(!x.children.Index || !typeof(x.children.Index.component)){ throw new Error('Component for index route of "' + x.title + '" is not defined') } route.indexRoute = { component: x.children.Index.component } route.childRoutes = Object.keys(x.children).map(y => { const cm = x.children[y] if (!cm.component) { throw new Error('Component for module ' + x + '/' + y + ' is not defined') } return { path: x.path + cm.path, title: cm.title, component: cm.component, onEnter: cm.onEnter ? routeParams => { cm.onEnter(routeParams, store.dispatch) } : null } }) } return route }) } ] 

Thus, adding a module to the modules/index.js will automatically initialize new routes. If the developer forgets to announce the route or gets confused in the agreements, he will see an unambiguous error message in the console.

onEnter


Note that the module can also export the onEnter function. To which, when switching to the corresponding route, the path parameters and the store.dispatch function will be transferred. This avoids the use of componentDidMount to initialize components. Instead, you can throw an event into the store (or Promise, if you, like me, decide to throw redux-saga and leave redux-thunk ), process it in the reducer, modify the state, thus causing the component to be redrawn.

We connect receivers to the stora
We will need DevTools and thunk. We declare a small function to initialize the store.

 const composeEnhancers = typeof window === 'object' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose; const createAppStore = (reducer, ...middleware) => { middleware.push(thunk) const store = createStore( reducer, composeEnhancers(applyMiddleware(...middleware))) return store } export default createAppStore 

And one more for receiving and arranging all the reducer for all modules:

 export const combineModuleReducers = modules => { const reducers = {} const flat = flatModules(modules) for (let i = 0; i < flat.length; i++) { const red = flat[i].reducer if (typeof(red) !== 'function') { throw new Error('Module ' + i + ' does not define reducer!') } reducers[flat[i][MODULE]] = red if(typeof(flat[i].children) === 'object'){ for(let j in flat[i].children){ if(typeof(flat[i].children[j].reducer) !== 'function'){ throw new Error('Module ' + j + ' does not define reducer!') } reducers[j] = flat[i].children[j].reducer } } } return reducers } 

It is possible to make less strictly and simply skip modules that do not contain reducer, and not fall with the exception, but I like a more strict approach. If the module does not contain any logic at all, it is easier to arrange it simply as a component and add it to the router manually.

We combine everything in the file store.js


 export default createAppStore(combineReducers(combineModuleReducers(modules))) 


Now each module corresponds to a part of the state, which coincides with the key in the modules/index.js . For personal account it will be Profile
I have everything about the structure of modular applications. The organization of the "core" and the provision of public API modules - the topic of a separate article.

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


All Articles