Redux composition and encapsulation at a large scale
Edit: As of November 2021, I am not recommending using Redux for sharing API data across React applications. There used to be a time where it was the best way, but today there are amazing libraries out there such as React-Query resolving API state management perfectly well.
Building a large scale application can be problematic, and as it grows it becomes more and more challenging and crucial to keep the structure organized. Redux provides great benefits from having a global application state, but on the other hand, it can become messed up easily and break your app.
I previously demonstrated How to better organize your React applications, where I expose the benefits of a feature-based architecture for React applications. If you require to use Redux, you might wonder how you can use this structure and apply the same type of separation of concerns without the pain.
I have recently worked on the first version of a React-Native mobile app for one of my clients, which desired to architecture the app in a scalable way for a painless experience to add new features later. I believe we should always think about app architecture that way as it can always scale up later on, and I think it is a great idea to show you how I managed it on the Redux side of things with a few examples of code. Be inspired and adapt it to your own need.
Why does it get difficult to maintain my Redux state as my app grows?
Redux is a very great tool and you might want to use it for many reasons. You might need to share some state from a specific component without the need to pass it through props, get data from a server API and share it across your app, keep a history and time-travel over the actions or even store the app state in the local storage of the user’s device to restore it later. But never forget, you might not need redux.
If you decide to use Redux, it’s important to keep the state tree well structured so it can grow as your app grows.
When you have issues with your Redux state, chances are you may have issues maintaining your entire application as well for the same reasons.
- Do you have too many global action creators?
- Do you have a huge reducer that manages everything?
- Do you have conflicts between your actions or reducers that modify the same piece of state?
In most cases, it results from losing control and understanding of your app behaviour, and you end up spending more time debugging than coding. True story.
Add your Redux files with your feature
You can tell when an application has a good architecture when it’s divided into several small features that can be easily extracted and published on a package manager and reused by another application. There is nothing worse than browsing an old project folder directory and not being able to tell if some functions or resources are still in use somewhere in the app because someone forgot to delete them while refactoring some code.
Therefore, you would want to define your Redux actions creators and reducers within the feature that consumes it to keep track of what is used and where it’s used. If you decide you want to remove an entire feature of your app, you can simply unplug it and delete that entire folder which contains the container and presentational components, reducers, actions, resources etc, and you know it will be fine because the state is not coupled to any other feature. Quick, clean and easy.
As your application grows, your features become more complex. To avoid having a single feature that manages too many things, you need to separate them into several smaller features.
A feature must have everything it needs to work on its own. It must also have a limited scope and ideally no awareness of the entire app, but that sometimes is difficult as container components will need to be aware of the shape of the Redux state. Of course, you can nest features into features as long as they stay as much standalone as possible and the nested ones get only used by their direct parent.
Books is a scene, one feature of an app. It has a sub-feature
ListItems which is a simple presentational component. The scene also has its own
reducers defined in the feature’s folder where the action creators are used by the container component of the feature.
The same way you compose your features into smaller ones, you should also apply this rule to your reducers and divide them, so they are easy to read and maintain. With such a composition, it is preferable to keep the structure of your entire Redux state tree following the folder structure of your features.
Create standalone data modules
Most of the time, container components consume data that are not tightly bound to a specific component and you might wonder where the action creators and reducers are supposed to be defined. A way to do it is to create data modules that take care of individual data objects.
Data module core’s responsibility is to manage a chunk of data of your Redux state. As an example, you can have a module that manages a
users object and another one for a
books object. You can nest data modules into each other as long as they stay standalone, the same way how features are assembled.
The anatomy of a data module looks like this:
They take care of the business logic part. They receive a payload, transform it as needed and return an action that gets dispatched to update the Redux store. In most projects, you might also want to use redux-thunk in order to have asynchronous actions such as sending requests to the server API.
Reducers and Selectors
Reducers handle actions and update the Redux state. There shouldn’t be any logic in your reducers as they need to stay pure. If you tend to have logical code, you should create another action instead.
Selectors are important if you’d like to filter your state before using them in your container components. You can define them in the
reducer.js file as they are bound to the same chunk of the state.
I recommend you to create a file called
api.js where you can define your network requests.
I’ve seen many projects where the only way to send an API request was via a Redux middleware, suggesting that every call will have an impact on the Redux store. But this is not true, you could have API calls that don’t impact the store, such as a simple
POST request to send an email, it depends on how you’ve organized your app. But for this reason, I prefer to define them in a separate file.
Once the data module is defined, you just have to import and combine its reducer with the data reducer which is combined itself along with your feature’s main
Assemble multiple features into one
Now that your features are standalone with their own actions and reducers, you need to assemble them by applying the concept of reducer composition, where a reducer is called by another reducer.
In the example below, there are 2 data modules. The
users data module is defined globally, which means any feature can import an action creator from it and use it to make a change in the
users sub-tree. The
books data module though is nested in the
Books scene as it only gets used within the
Books feature. In order to have everything working, you need to assemble the main reducer of each child feature into their parent feature’s reducer. You can do that with
combineReducers or manually if you have a complex data structure.
data folders, we have a reducer in charge of combining all data modules defined in that folder. Even though the reducer might only return a single data module, in the advent of additional data modules being added in the future and to keep everything consistent across the app, it’s better to have them all imported and combined in that single reducer so your feature’s reducer can simply import and use it to make all data modules available at once. The feature’s reducer then gets used by its direct parent’s feature reducer or in the root reducer if it’s defined at the global level.
This is how the
scenes/Books encapsulating the data modules looks like:
In this example, the reducer catches actions from the data module in order to set the loading state whenever there is a request to load new data. You can handle the same action in several reducers or vice versa, mutating what is relevant. This helps you scale your app development because different people on your team might work on different features and data modules handling the same actions without causing merge conflicts while bumping into each other’s code.
Once you’ve properly combined the reducers together for all your features, the Redux state tree of your app should look like this:
Now that your reducers are assembled following this structure, your container components can connect to the Redux store and use the selectors you’ve previously defined to consume the data and dispatch actions of your feature to make changes in the store.
I published on my Github account a sample project that follows this structure: https://github.com/alexmngn/react-feedback-form
Feel free to add a response below or contact me directly if you have any questions, I’ll be more than happy to help.
More articles from me
- How to better organize your React applications?
- What are the main differences between ReactJS and React-Native?
- The essential boilerplate to authenticate users on your React-Native app