# Introduction

The React Modular Design System is an application of the separation of concerns (SoC) design principle adapted to React applications.

The goals of the React Modular Design System is to ease the development, maintainability and testing of the React applications. Following this system, all the responsibilities should be splitted into separated folders and technical concerns splitted into separated files.

This guide aims to explain the React Modular Design System approach and do not promote nor impose any kind of naming nor convention but mostly propose it. Keep in mind that the React Modular Design System is a design principle and not a framework.

# Modules

First, what's a module?

A module is a composition of one to infinite numbers of files that all have different technical concerns but that are all centered about the same responsibility.

In other words, a module is a self-contained program that expose one or more interfaces that are all centered about the same responsibility (e.g. user session).

This program can have any number of functions or files to achieve its goal. A module can require or import as many modules it needs to achieve its goal. But you should try to keep the module small and centered around one responsibility.

A module should be in its own directory and all of its files and sub-modules in the same directory. In this manner, a composed module can be replaced or deleted from the JavaScript program without any major side effects.

One could say that a complex JavaScript program is a collection of modules that could be replaced with a module with the same public interface.

# Modules structure

Directories represent modules in a JavaScript program, so they should not be organized around technical concerns but centric to a responsibility and separated as such.

// bad
_ src
β”œβ”€β”€ app.js
β”œβ”€β”€ store.js
β”œβ”€β”€ actions
|   └── index.js
β”œβ”€β”€ apis
|   └── apple.js
|   └── microsoft.js
β”œβ”€β”€ components
|   └── apple.js
|   └── microsoft.js
β”œβ”€β”€ containers
|   └── apple.js
|   └── microsoft.js
β”œβ”€β”€ epics
|   └── apple.js
|   └── microsoft.js
β”œβ”€β”€ reducers
|   └── apple.js
|   └── microsoft.js
β”œβ”€β”€ selectors
|   └── apple.js
|   └── microsoft.js
└── styles
    └── apple.pcss
    └── microsoft.pcss

// good
_ src
β”œβ”€β”€ app.js
β”œβ”€β”€ redux
|   └── store.js
|   └── reducer.js
|   └── effects.js
└── modules
    β”œβ”€β”€ connector.js
    β”œβ”€β”€ apple
    |   └── apple.style.css
    |   └── apple.action.js
    |   └── apple.api.js
    |   └── apple.component.js
    |   └── apple.container.js
    |   └── apple.effect.js
    |   └── apple.reducer.js
    |   └── apple.constant.js
    |   └── apple.accessor.js
    |   └── apple.connector.js
    └── microsoft
        └── microsoft.style.css
        └── microsoft.action.js
        └── microsoft.api.js
        └── microsoft.component.js
        └── microsoft.container.js
        └── microsoft.effect.js
        └── microsoft.reducer.js
        └── microsoft.accessor.js
        └── microsoft.connector.js

# Modules files

A module is composed of one to infinite numbers of files.

All of these files have all different technical concerns (e.g. styling, reducer, api calls) but are all centered about the same responsibility (e.g. user profile).

In this section, you'll find different kind of files based on different technical concerns. A module don't need all those files to work. In fact, a module can be a folder with only one file in it.

# Naming

The files should have the same name as their module. Each file have a sub-extension representing their technical concern. For example, microsoft.style.css is a file inside the module, microsoft and his technical concern is about the styling.

You can create any sub-extension and change the sub-extension names has you pleased. As long as this sub-extension represent a particular technical concern that you want to separate from the others.

It's recommended, for ease of usage, to name the sub-extension with singular.

# Component file

In the litterature, they also can be called dumb component or presentational component because their only technical concern is to present something to the DOM. Once that is done, the component is done with it.

By React convention, the name of the exposed component should start with a capital letter (e.g. Counter).

// src/modules/counter/counter.component.jsx

import styles from "./counter.module.css";

export const Counter = ({ count, increase, decrease, reset }) => {
  const handleIncreaseClick = () => increase();
  const handleDecreaseClick = () => decrease();
  const handleResetClick = () => reset();

  return (
    <div className={styles.component}>
      <h2>Counter: {count}</h2>
      <button onClick={handleIncreaseClick}>Increase</button>
      <button onClick={handleDecreaseClick}>Decrease</button>
      <button onClick={handleResetClick}>Reset</button>
    </div>
  );
};

# Stateless VS Component Class

A component can be write with the Stateless or the Component Class way.

import { Component } from "react";

// Stateless
const MyStatelessComponent = ({ name }) => <div>{name}</div>;

// Component Class
class MyComponentClass extends Component {
  constructor(props) {
    super(props);
  }

  public render() {
    const { name } = this.props;

    return <div>{name}</div>;
  }
}

By default, always use the Stateless way. Use the Component Class way only when you have to.

# Container files

In the litterature, they also can be called smart component or container component.

The container technical concern is to connect to Redux and get the states and actions required by the component. The container do the heavy lifting and pass the data down to the component as props.

By React convention, the name of the exposed Container should start with a capital letter. However, to help distinguish container component from the presentational component, you should also add the suffix Container (e.g. CounterContainer).

// src/modules/counter/counter.container.js

import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectCount } from "./counter.accessor";
import { actions } from "./counter.action";
import { Counter } from "./counter.component";

// Map Redux state to component props
const mapStateToProps = createStructuredSelector({
  count: selectCount,
});

// Map Redux actions to component props
const mapDispatchToProps = {
  increase: actions.increase,
  decrease: actions.decrease,
  reset: actions.reset,
};

export const CounterContainer = connect(
  mapStateToProps,
  mapDispatchToProps
)(Counter);

# Style files

They can be any kind of styling as you whish (vanilla CSS, CSS Modules, PostCSS, JSS, SCSS, Stylus, Styled, Emotion, etc.). However, they should be separated from the component since they have different technical concerns. Style files are used for definition of content presentation style when Component files are mainly used for organization of the content.

If you the have choice, always prefer a scoped CSS over a global CSS.

# Action files

Those files contains and expose only the redux actions.

// src/modules/counter/counter.action.js

import { createAction } from "@reduxjs/toolkit";

export const actions = {
  increase: createAction("COUNTER/INCREASE"),
  decrease: createAction("COUNTER/DECREASE"),
  reset: createAction("COUNTER/RESET"),
};

For ease of usage, the exposed constants names should be actions.

The actions types values format are $1/$2 where $1 = the namespace of the module and $2 = the action. The letters formatting is UPPERCASE.

The actions values are functions created with createAction from the library @reduxjs/toolkit. Actions don't contain any logic nor modify the data. They mostly just act as trigger function that can sometime transmit data in the same format that they received it.

# Reducer files

Reducer files contains Redux reducers. Reducers specify how the application's state changes in response to actions sent to the store. Remember that actions only describe what happened, but don't describe how the application's state changes. This is the reducers job.

The Reducer files should only expose a reducer constant that is one single reducer from a combineReducers.

// src/modules/todos/todos.reducer.js

import { combineReducers } from "redux";
import { createReducer } from "@reduxjs/toolkit";
import { actions } from "./todos.action";
import {
  getTodosExceptIdFromTodos,
  getTodosWithUpdatedTodoFromTodos,
} from "./todos.accessor";

const isFetching = createReducer(false, {
  [actions.getTodosStart.type]: () => true,
  [actions.toggleTodoStart.type]: () => true,
  [actions.createTodoStart.type]: () => true,
  [actions.removeTodoStart.type]: () => true,

  [actions.getTodosSuccess.type]: () => false,
  [actions.toggleTodoSuccess.type]: () => false,
  [actions.createTodoSuccess.type]: () => false,
  [actions.removeTodoSuccess.type]: () => false,

  [actions.getTodosFailure.type]: () => false,
  [actions.toggleTodoFailure.type]: () => false,
  [actions.createTodoFailure.type]: () => false,
  [actions.removeTodoFailure.type]: () => false,
});

const error = createReducer("", {
  [actions.getTodosStart.type]: () => "",
  [actions.toggleTodoStart.type]: () => "",
  [actions.createTodoStart.type]: () => "",
  [actions.removeTodoStart.type]: () => "",

  [actions.getTodosSuccess.type]: () => "",
  [actions.toggleTodoSuccess.type]: () => "",
  [actions.createTodoSuccess.type]: () => "",
  [actions.removeTodoSuccess.type]: () => "",

  [actions.getTodosFailure.type]: (_, { payload }) => JSON.stringify(payload),
  [actions.toggleTodoFailure.type]: (_, { payload }) => JSON.stringify(payload),
  [actions.createTodoFailure.type]: (_, { payload }) => JSON.stringify(payload),
  [actions.removeTodoFailure.type]: (_, { payload }) => JSON.stringify(payload),
});

const todos = createReducer([], {
  [actions.getTodosSuccess.type]: (_, { payload }) => payload,
  [actions.createTodoSuccess.type]: (todos, { payload }) => [...todos, payload],
  [actions.removeTodoSuccess.type]: (todos, { payload }) =>
    getTodosExceptIdFromTodos(payload)(todos),
  [actions.toggleTodoSuccess.type]: (todos, { payload }) =>
    getTodosWithUpdatedTodoFromTodos(payload)(todos),
});

// For the demo purpose, only set the userId to 1
const userId = createReducer(1, {});

export const reducer = combineReducers({ isFetching, error, todos, userId });

# Accessor files

Accessors are simply functions that are used to get a subset data from a larger data collection. Also, accessors can modify the output of the data so it's gonna fit with the needs of the requester.

The accessors name formats are:

// If the accessor source is the Redux State
select[WhatData]

// If the accessor source is NOT the Redux State
get[WhatData]From[WhatDataSource]

# Simple example

Get, in an array of object users, the users born between 1990 and 2000 and return those users in an object where the key is the userId and the value is all the user infos (try it on repl).

import { pipe, filter, indexBy, prop } from "ramda";

const usersArray = [
  { userId: 1, dob: 1987, city: "MTL" },
  { userId: 2, dob: 1997, city: "LA" },
  { userId: 3, dob: 1991, city: "NYC" },
  { userId: 4, dob: 1984, city: "MTL" },
  { userId: 5, dob: 2004, city: "LA" },
  { userId: 6, dob: 2001, city: "NYC" },
];

const getUsersDOBRangeFromUsersArray = ({ start, end }) =>
  pipe(
    filter(({ dob }) => dob >= start && dob <= end),
    indexBy(prop("userId"))
  );

getUsersDOBRangeFromUsersArray({ start: 1990, end: 2000 })(usersArray);
//=> {"2": {"city": "LA", "dob": 1997, "userId": 2}, "3": {"city": "NYC", "dob": 1991, "userId": 3}}

# Why use accessors

  • Accessors (selectors) can compute derived data, allowing Redux to store the minimal possible state.
  • Accessors are efficient. If memoized, an accessor is not recomputed unless one of its arguments changes.
  • Accessors are composable. They can be used as input to other accessors as long as their outputβ†’input match.

# Ramda library

To help us work more efficiently, use the powerfull Ramda library. Ramda is not the only way to go, you still can write vanilla JS code if the code is more clear to read like that. Also, Ramda is designed specifically for a functional programming style, one that makes it easy to create functional pipelines, one that never mutates user data.

# Reselect library

Reselect is a library that add some memoization to your accessors so you don't recompute them again and again when the arguments they takes don't change. Reselect also have an createStructuredSelector method that help you output an formated object from multiple accessors that use the same source. Reselect is specialized for Redux, but you still can use it with any data source.

# Complete example

// src/modules/todos/todos.accessor.js

import * as R from "ramda";
import { createSelector } from "reselect";
import { NAMESPACE } from "./todos.constant";

export const selectError = createSelector(
  R.pathOr("", [NAMESPACE, "error"]),
  (error) => error
);

export const selectIsError = createSelector(selectError, (error) => !!error);

export const selectIsFetching = createSelector(
  R.pathOr(false, [NAMESPACE, "isFetching"]),
  (isFetching) => isFetching
);

export const selectUserId = createSelector(
  R.pathOr(0, [NAMESPACE, "userId"]),
  (userId) => userId
);

export const selectTodos = createSelector(
  R.pathOr([], [NAMESPACE, "todos"]),
  (todos) => todos
);

export const selectReversedTodos = createSelector(selectTodos, (todos) =>
  R.reverse(todos)
);

export const selectTodoById = (todoId) =>
  createSelector(
    selectTodos,
    R.pipe(
      R.filter(({ id }) => id === todoId),
      R.pathOr({}, [0])
    )
  );

export const getTodosExceptIdFromTodos = (todoId) => (todos) =>
  createSelector(
    R.filter(({ id }) => id !== todoId),
    (todos) => todos
  )(todos);

export const getTodosWithUpdatedTodoFromTodos = (updatedTodo) => (todos) =>
  createSelector(
    R.map((todo) => (todo.id === updatedTodo.id ? updatedTodo : todo)),
    (todos) => todos
  )(todos);

# Effect files

Effect files contains Redux Effects. Those effects can be Epics, Sagas, or any other Redux Effects of your choice.

The Epic files should expose an effects array that contain the effects that you want to expose.

An example using redux-observable

// src/modules/todos/todos.effect.js

import { ofType } from "redux-observable";
import { merge, of } from "rxjs";
import { catchError, filter, map, switchMap } from "rxjs/operators";
import { actions } from "./todos.action";
import { callGetTodosByUserId } from "./todos.api";
import { selectIsFetching, selectUserId } from "./todos.accessor";

const watchGetTodos = (action$, state$) =>
  merge(
    action$.pipe(
      ofType(actions.getTodos.type),
      filter(() => {
        const { value: state = {} } = state$;

        return !selectIsFetching(state);
      }),
      map(({ payload, meta }) => actions.getTodosFetch(payload, meta))
    ),
    action$.pipe(
      ofType(actions.getTodosStart.type),
      switchMap(() => {
        const { value: state = {} } = state$;
        const userId = selectUserId(state);

        return callGetTodosByUserId(userId);
      }),
      map(({ data }) => actions.getTodosSuccess(data)),
      catchError((err) => of(actions.getTodosFailure(err)))
    )
  );

export const effects = [watchGetTodos];

Same example using redux-saga

// src/modules/todos/todos.effect.js

import { call, put, select, take, fork } from "redux-saga/effects";
import { actions } from "./todos.action";
import { callGetTodosByUserId } from "./todos.api";
import { selectIsFetching, selectUserId } from "./todos.accessor";

function* watchGetTodos() {
  while (true) {
    yield take(actions.getTodos.type);
    const isFetching = yield select(selectIsFetching);

    if (!isFetching) {
      yield put(actions.getTodosStart());
      yield fork(forkGetTodos);
    }
  }
}

function* forkGetTodos() {
  try {
    const userId = yield select(selectUserId);
    const { data } = yield call(callGetTodosByUserId, userId);

    yield put(actions.getTodosSuccess(data));
  } catch (err) {
    yield put(actions.getTodosFailure(err));
  }
}

export const effects = [watchGetTodos];

# API files

Those files technical concern is about the communication with API's.

It's suggested to prefix the name of each exposed call's with call. This way, you can easily distinguish an accessor (that can start with get) from an API call.

// src/modules/todos/todos.api.js

import axios from "axios";

const baseURL = "https://jsonplaceholder.typicode.com";

export const callGetTodosByUserId = async (userId) =>
  axios({
    baseURL,
    url: `todos?userId=${userId}`,
    method: "get",
  });

export const callPostTodo = async (data) =>
  axios({
    baseURL,
    url: `todos`,
    method: "post",
    data,
  });

export const callPutTodo = async (data) =>
  axios({
    baseURL,
    url: `todos/${data.id}`,
    method: "put",
    data,
  });

export const callDeleteTodoByTodoId = async (todoId) =>
  axios({
    baseURL,
    url: `todos/${todoId}`,
    method: "delete",
  });

The data formating, error handling and cancellation are not the concerns of the API files.

  • Request and Response formating is done in the Effect files that can also use Accessor files to do it.
  • Cancellation is done in the Effect files.
  • Error handling is done in the Effect files.

# Constant files

Those files contain only constants. Values that absolutely needs to be shared between files or modules and that will never change.

// src/modules/todos/todos.constant.js

export NAMESPACE = "todos";

# Connector files

The single goal of those files is to help automate the connections of the modules reducers and effects with the Redux Store.

For automation purpose, the exposed constants names should be NAMESPACE, reducer and/or an effects so the core will be able to automatically connect with those. You'll understand better this part on the Root Reducer and Root Effects sections.

// src/modules/todos/todos.connector.js

import { reducer } from "./todos.reducer";
import { effects } from "./todos.effect";
import { NAMESPACE } from "./todos.constant";

export { reducer, effects, NAMESPACE };

# Sub-modules

Sometime, your module gonna need other modules to split correctly the UI. And sometime, those modules will not be used by any other module except the main module.

In those case, it's possible to create sub-modules. But before, you have to follow a set of rules:

  • Sub-modules should be just UI. style and component files. NOT container files. The logic is always on the main module.
  • Sub-modules style files using non-modular approach (e.g. pure CSS files) should be imported inside the main module style file (e.g. @import "./sub-modules/apple-button/apple-button.style.css";) so if the module need to be connected to another CSS file, this file will only import the style file at the root of the module.
  • Only the files inside the module can access the sub-module. Not the files and modules outside of it.
\_ src
  └── modules
    └── apple
      └── apple.style.css
      └── apple.action.js
      └── apple.api.js
      └── apple.component.js
      └── apple.container.js
      └── apple.effect.js
      └── apple.reducer.js
      └── apple.constant.js
      └── apple.accessor.js
      └── apple.connector.js
      └── sub-modules
        β”œβ”€β”€ apple-button
        | └── apple-button.style.css
        | └── apple-button.component.js
        └── apple-search-input
          └── apple-search-input.style.css
          └── apple-search-input.component.js

# Root Connector

The Root Connector role is to expose all the connectors in your modules. You can place this file at any place in your project. It's suggested to put it at the root of the folder where you put all of your modules.

There's an example of a Root Connector file.

// src/modules/connector.js

import * as moduleA from "./module-a/module-a.connector";
export { moduleA };

import * as mobuleB from "./module-b/module-b.connector";
export { mobuleB };

import * as mobuleC from "./module-c/module-c.connector";
export { mobuleC };

Since the Connector files inside of some modules expose NAMESPACE, reducer and/or an effects, by importing all the exposed constants (with *), assignin it to another constant and then exposing it from the Root Connector, the exposed values of the Root Connector will be something like that.

{
  moduleA: {
    NAMESPACE: "module-a",
    reducer: {...},
    effects: [...]
  },
  moduleB: {
    NAMESPACE: "module-b",
    reducer: {...}
  },
  moduleC: {
    NAMESPACE: "module-c",
  },
}

With Root Connector and Connector files you can easily add and remove module from your application without doing a ton of rewiring with the Redux Store simply by adding/removing the import/export lines corresponding to this module.

Simply remember that if you need to connect your module to the Redux Store, you need a correctly formed Connector file inside your module folder and then you need to import and export this module in the Root Connector.

# Root Reducer

Using the Root Connector, you can now create your Root Reducer. This file will grab all the reducers exposed in your Root Connector and will combine them using their module NAMESPACE as key.

There's an example of a Root Reducer file.

// src/reducer.js

import * as R from "ramda";
import { combineReducers } from "redux";
import * as modules from "@src/modules/connector";

export const reducer = R.pipe(
  R.toPairs, //=> [[moduleA, {NAMESPACE, reducer, effects}], [...], [...]]
  R.map(R.pipe(R.last, ({ NAMESPACE, reducer }) => [NAMESPACE, reducer])), //=> [["module-a", reducer], [...], [...]]
  R.fromPairs, //=> {"module-a": reducer, ..., ... }
  R.reject((reducer) => !reducer), //=> {"module-a": reducer, ... }
  combineReducers
)(modules);

# Root Effects

The Root Effects act exactly like the Root Reducer, but for Effects.

There's an example of a Root Effects file.

// src/effects.js

import * as R from "ramda";
import * as modules from "@src/modules/connector";

export const effects = R.pipe(
  R.toPairs, //=> [[moduleA, {NAMESPACE, reducer, effects}], [...], [...]]
  R.map(R.pipe(R.last, ({ effects }) => effects)), //=> [[effects], [undefined], [undefined]]
  R.unnest, //=> [effects, undefined, undefined]
  R.reject((effect) => !effect) //=> [effects]
)(modules);

# Store

Now that you have the reducer and the effects, we can create the store.

There's an example of a Store file using redux-saga and next-redux-wrapper for Next.js. This store works on the server and the client side.

// src/store.js

import * as R from "ramda";
import {
  configureStore as reduxConfigureStore,
  getDefaultMiddleware,
} from "@reduxjs/toolkit";
import createSagaMiddleware from "redux-saga";
import { fork, all } from "redux-saga/effects";
import { effects } from "./effects";
import { reducer } from "./reducer";

export const configureStore = (
  preloadedState = {},
  { isServer, req = null } = {}
) => {
  const rootSaga = function* () {
    yield all(R.map(fork)(effects));
  };

  const sagaMiddleware = createSagaMiddleware();

  const store = reduxConfigureStore({
    reducer,
    middleware: [...getDefaultMiddleware(), sagaMiddleware],
    preloadedState,
    enhancers: [],
  });

  if (req || !isServer) {
    store.sagaTask = sagaMiddleware.run(rootSaga);
  }

  return store;
};

As you see, we can reuse you effects and reducer without doing anything. It's the store technical concern to adapt to the environment, not the effects nor the reducer.

# Application

Now that you have modules, connector, reducer, effects and store, you have everything you need to create a React application with a Redux Store and a side effects manager.

However, as explained in the introduction, keep in mind that the React Modular Design System is a design principle and not a framework. In this guide, we've talked about Reducers, Connectors, Effects and Store in order to cover most of the case in the React ecosystem.

You don't need them to use the React Modular Design System. All you need is React (and love).

Having an application with all the responsibilities splitted into separated folders and technical concerns splitted into separated files make a huge difference when it came to development, maintainability and testing.

You now have the power to:

  • Easily remove or add a feature to your application without changing 50% of your codebase.
  • Integrate a new developer to your team without having to explain a lot about your codebase since it self explanatory.
  • Add specialised developers to your teams (e.g. styling developer that just touch the component and style files).
  • Obtain a realistic code coverage by filtering by files sub-extensions (e.g. *.accessor.js).
  • Having more meaningfull and faster code reviews on files that really have an impact.
  • Reduce the mental tax of your codebase since everything is at the same place.
  • Introduce less errors and keep the dead code to zero.
  • ... and more!

To help you understand better the React Modular Design System and how you can apply its principles, an example build with Next.js is available to play with.