A new approach to managing Redux actions

I’ve been using Redux for about one year and enjoyed it for creating a pretty large project. Redux really helped a lot for managing the whole application state with good scalability. However, one problem is, after creating more and more actions/reducers, the actions.js and reducer.js files became too large, even we had separated the application logic into different features.

See below code snapshot for example:

It even takes the first screen just for constants import! This happens for both actions.js and reducer.js. Whenever it needs to change some action logic, it takes much time to navigate to the right code for the action or reducer because they are really TL;DR…To resolve the issue, we tried to put one action in one file and put the corresponding reducer into the same file. It worked well and resolved the pain. We call it One action one file pattern.

Actually it’s also the approach used in our toolkit: http://rekit.js.org

One action one file, with its reducer

The approach is separating actions into different files: use the action name as the file name and it only contains one action. Take a simple counter component for example:

It needs 3 types of actions: COUNTER_PLUS_ONE, COUNTER_MINUS_ONE, COUNTER_RESET. Then we create 3 actions files (we usually create a redux folder for actions): counterPlusOne.js, counterMinusOne.js, counterReset.js.

Whenever creating an action, it usually and immediately needs to create a reducer to handle it to update the store. And during the development, we need to touch both files frequently. Switching files really takes much time. So we also put the reducer into the same file, this also helps to avoid the reducer file to be too long. For example, thecounterPlusOne.js contains below code:

import {
COUNTER_PLUS_ONE,
} from './constants';
export function counterPlusOne() {
return {
type: COUNTER_PLUS_ONE,
};
}
export function reducer(state, action) {
switch (action.type) {
case COUNTER_PLUS_ONE:
return {
...state,
count: state.count + 1,
};
    default:
return state;
}
}

Actually a reducer usually always corresponds to some action and it’s rarely used globally. So putting the reducer into the same file makes sense. It well groups the application logic for a single functionality in one place which makes development easier. For async actions, there may be two or more actions because it needs to handle errors.

You may have noticed the reducer here doesn’t have a initial state so it’s not a standard Redux reducer. It’s only imported and called by a wrapper reducer.

The wrapper reducer

As mentioned, we group application logic into features, each feature is a folder. For example, a forum application typically has these features: user, topic, comment, etc. Each feature has a wrapper reducer, we put it at the redux folder of a feature and name it reducer.js.

The wrapper reducer is responsible for loading other reducers defined in different actions, and call them one by one to generate a new state when receiving a new action. The shape of a wrapper reducer is always like below:

import initialState from './initialState';
import { reducer as counterPlusOne } from './counterPlusOne';
import { reducer as counterMinusOne } from './counterMinusOne';
import { reducer as counterReset } from './counterReset';
const reducers = [
counterPlusOne,
counterMinusOne,
counterReset,
];
export default function reducer(state = initialState, action) {
let newState;
switch (action.type) {
// Put global reducers here
default:
newState = state;
break;
}
return reducers.reduce((s, r) => r(s, action), newState);
}

From the code we can see: the wrapper reducer itself is also a place to write reducers. It and reducers from actions operates on the same branch of the store. This is the key difference between a standard reducer with the one defined in an action file. You can think a wrapper reducer as a standard Redux reducer while others are just pure functions.

Handling cross-topic actions

As mentioned above, not every action only has one reducer. Some reducers may need to handle the same action. Take an embedded chatting functionality for example, when a new message comes:

  1. If chatting box is open, display it;
  2. If not, show a notification icon/message.

Here the NEW_MESSAGE action needs to be processed by different UI components. So we shouldn’t put different reducers into some action file neither by application logic nor technical structure. So the right place is the wrapper reducer. As the code above shows, a wrapper reducer itself is a standard Redux reducer, we can put any switch case in it so that all cross-topic actions could be handled.

Benefits

This approach has multiple advantages:

  1. Easier to develop: no need to jump between files when creating actions.
  2. Easier to maintain: action files are now small and easy to be found just by file name.
  3. Easier to test: one test file corresponds to one action which also includes both action and reducer test.
  4. Easier to create tools: no need to parse code when creating a tool to generate Redux boilerplate code. A file template is enough.
  5. Easy to analyse: cross-topic actions could be easily found by static analysis.

Creating a code generator

Either the official way or this approach for creating Redux actions/reducers, we all need boilerplate code for different technical code structure. Writing it manually is really complicated and easy to make mistakes. So we’d better create tools for us to generate such code. Now “one action one file” pattern makes it easier to create such a tool. For example, when creating a normal action, we can use below template:

import {
${ACTION_TYPE},
} from './constants';
export function ${CAMEL_ACTION_NAME}() {
return {
type: ${ACTION_TYPE},
};
}
export function reducer(state, action) {
switch (action.type) {
case ${ACTION_TYPE}:
return {
...state,
};
    default:
return state;
}
}

All we need to do is creating variables for action type and action name based on arguments, then generate the code by the template. To be better, the tool could auto create an action type in constants.js and auto import the reducer in the wrapper reducer file. Since it’s a very fixed code pattern, it’s easy for a tool doing this. Actually we have created a tool Rekit to create such boilerplate.

Summary

The approach is opinionated but it worked for our project well. Based on our practice on React, Redux and React-router, we also created a toolkit named Rekit (http://rekit.js.org) which makes the approach re-usable, at least for ourselves, and hopefully for you too.

Feel free to leave any comment or contact me on Twitter: @webows

Show your support

Clapping shows how much you appreciated Nate Wang’s story.