There are a lot of different ways to have a system built around React and Redux. This is mainly because both frameworks are not opinionated on how developers leverage them. In this article, I’ll present a simple architecture that uses React and Redux. The idea is to not complicate the life cycle by avoiding an excess of third-party libraries and instead focus on some core ones.
The premise of having simple code starts with having simple UI elements. This follows the React philosophy of having small and simple components. It makes code reading easier for developers who need to change it, makes writing unit tests faster, as well as reduces the side-effects that occur when modifying a piece of code. That being said, most of the component won’t have a state at all. By most I mean more than 95%.
Most of the stateless components will be presentation components. These do no connect to Redux – they get their data from their own properties and display it. When needing to take an action, they dispatch using the properties as well. There are multiple reasons for this, the main reason being reusability. You can reuse these components regardless of the lifecycle.
They are not tied to Redux or any other framework you may migrate to in the future. The second reason is simplicity. It is simpler to not have a dependency on the store for testing.
The second type of component is the container component. These components are the ones that are listening to the Redux’s store. Page component and heavy control like grids may be connected to the store. However, all controls within the pages or grids shouldn’t.
React-Redux is the main library used to glue React components to Redux and it is perfect to use for container component, but having too many of such components breaks the React hierarchical flow. That’s right if every component hooks to the store, any change to the store will notify every component instead of a few select ones that can then distribute the changes down. The flow is more predictable if goes top to bottom than every component getting notified. That being said, a balance is required as well. Otherwise, too many components will have huge properties list or have a very high-level property which makes them access too many values, thus confusing developers what they should really access.
Everything starts when a page is loaded. React-Router is a key third-party library at this point. Based on the URL that is active, it loads the right component. A pattern we see frequently is to have the ComponentDidMount method call Ajax to load data and then dispatch an action to have the data stored in Redux’s store. This will fire an event that the data in the store has changed which notifies all the components that are listening to the store. This is fine, but I rather not have a component start the process of the data fetching. The first reason is that a component should be very dumb. The mounting of a component which only performs display and/or dispatch operations doesn’t necessarily mean that we want to fetch the data. A change in the route, on the other hand, triggers the need for new data. Therefore, in this architecture, the loading of data is driven by a custom middleware that looks at the route change and executes the proper calls that will fill up the Reducer.
Business logics are the core of your system, especially when it is more than just a CRUD application where we take data from the server and throw it on the screen. In this architecture, all domain has their own middleware. A logic may request data from an API, manipulate it, ask for more data and when ready, store the data in Redux Reducer. The ideal position for business logic is in the middleware since they have not only had access to the actual store (getState()) but also to API call. It can be asynchronous with the help of the Redux-Thunk and fit well in an architecture with pre and post middleware that we will see soon.
Normalization and Store
Data comes in many formats and sometimes some entities are part of many data branches. Normalizing the data means to avoid any duplication of data inside the store even if the data is used in many branches. Normalizing should always be done before sending the data to the Store. This is why having a middleware to normalize the data and then send it to Reducers makes sense. Avoiding duplication is key to a stable system where the store is a source of truth.
Pre and Post Middleware
Every time an action is dispatched, the life cycle flow of information goes from React component to middlewares to reducers to the store which then notifies the React component. The pre and post middleware are middlewares located before and after the domain middleware.
Pre-Middlewares in this architecture are middlewares for thunk, operational, routing and authentication. Post-Middlewares are for normalization and telemetry. Let’s take a look at some of them to have a better idea. Post-Middleware prepare the data for business logic ones where the domain logic will be executed.
The Redux-Thunk library is the one that transforms action to be asynchronous. This is quite useful as it enables us to dispatch multiple actions from a single middleware. E.g. When you are loading data you might dispatch a “is loading” action, when the data is received “data loaded” action and finally “save data in reducer” action.
The operation middleware is not required here, but I found it useful to associate a unique id to a lifecycle flow. This data is used to tag logs into a specific lifecycle. The routing is divided into two middlewares. There is the official one from React-Redux which handles the history/location data. This is used to pass the routing information into the system. The second one is more interesting since it’s the one in which we will manually analyze the route and figure out if we want to load data when a route has changed. This middleware can dispatch many actions. For instance, it can set the active entity by looking at the URL, dispatch a “loading” action for a particular set of data and deconstruct URL to know if a whole page is changing or if the URL is for a deep link which may require different actions to be dispatched. In the end, this middleware’s role is to handle application logic in regard to a route change. A third one is an authentication. This one can look if the authentication token/cookie is still valid, if not to request for a refresh token before continuing the dispatch of action. I often queue every action until the authentication is validated. It can also use as an authorization gateway and if a particular credential fails to redirect. Being early in the pipeline you can block easily if needed.
Jumping to post-middleware, the most obvious one is a middleware that normalizes the data. This middleware listens to all activities that have a role in saving data to the reducer. It will normalize the data and send multiple dispatches to reducers. I recommend sending one dispatch per entity type since you can have a lot of reducers that are doing different manipulation per type. Since you should have one reducer per entity, it makes sense to have a more granular dispatching process. In the end, reducers will benefit by having a simpler immutability logic that is targeted to the reducer itself. The last post-middleware can be a telemetry one where you may want to capture some actions for telemetry purposes. See this middleware as a logging middleware that avoids cluttering domain middleware.
This architecture is not fancy which make it shine by being easy to read and to change. If you have a new page to build, you only need to create your dummy React component, a middleware for the page if it’s not from an existing domain and adjust the normalization and reducer (if new entities are required).