Last year, Lullabot was asked to help build a large-scale React application for a major U.S. media company and, lucky for me, I was on the team. An enterprise React application is often part of a complex system of applications, and that was certainly the case for this project. What follows is part one of our discussion. It includes a high-level view of the overall application architecture as well as a look at the specific architecture used for the React part of the project that was the focus of my work.
Web Application Architecture
Take a quick look at the diagram below. It describes the high-level architecture of the collection of applications that comprise the site. It begins with the content management system, or CMS. In this project, the CMS was Drupal, but this could just as well have been Wordpress or any number of alternatives—basically, software for editorial use that allows non-technical content creators to add pages, define content relationships, and perform other common editorial tasks. No pages or views are served to users directly from the CMS.
It’s not uncommon to have additional data sources besides the CMS feeding the API, as was the case on this project. In that sense our diagram oversimplifies things, but it does give a good sense of the data flows.
The API The API is software that provides a consistent view into the data of the CMS (as well as other data sources, when present). The database in a CMS like Drupal is normalized. One important task of the API is to de-normalize this data, which allows clients—web browsers, mobile apps, smart TVs, etc.—to make fewer round trips.
The API is a critical part of the application. The real business case for an architecture like this is to have a single data source serve content across a range of platforms in a consistent, efficient way. Client devices make an HTTP request to the API and receive a response with the requested data, usually in JSON format.
Caching Layers Having a caching layer in front of your API and Node.js servers helps reduce load on the API server and decreases response time. Our client uses Akamai as a CDN and caching solution, but there are many other options.
Node.js Server We’ve finally gotten to where the code for the React web app lives. We used the server-side JavaScript application framework, Express, on the project. Express was used to create an HTTP server that responds to requests from web clients. It’s also where we did the server-side rendering of the React application.
Clients In the diagram, I’ve added icons to represent mobile apps and web browsers, respectively, but any number of devices may consume the API’s data For example, our client serves not only web browsers and a mobile app from the API, but also Roku boxes and Samsung TVs.
What’s happening with the web client is pretty interesting. The first request by a browser goes to the Node.js server, which will return the pre-rendered first page. This is server-side React and it’s helping provide a faster load time.
Without rendering first on the server, the client would have to retrieve the page and then begin rendering, creating a lag on first load as well as potentially having an adverse impact on SEO. Subsequent pages will be routed on the client using a library like React Router. The app will then make requests directly to the API for the data it needs for a specific “page” or route.
React Architecture
When thinking of JavaScript application architecture, the MVC pattern immediately comes to mind for many people. React isn’t a MVC framework like Angular or Ember, but is instead a library that handles views. If that’s the case, then what architecture does a typical large-scale React application use?
On this project, we used Redux. Redux is a both a library and an architecture/pattern that is influenced by the Flux architecture from Facebook.
Two distinguishing characteristics of Redux are strict unidirectional data flow (shared with Flux) and storing all application state as a single object. The data lifecycle in a Redux app has four steps:
- Dispatch an action.
- Call to a reducer function.
- A root reducer combines the output of the various reducer functions into a single state tree.
- The store saves the state returned by the root reducer triggering view updates.
The diagram below illustrates this data flow:
Let’s walk through this step by step.
1. Dispatch an action Let’s say you have a list of items displayed in a component (represented by the blue box in the diagram) and you’d like the user to be able to delete an item when clicking a button next to the item. When a button is clicked, there will be a call to the Redux dispatch function. It’s a common pattern to also pass a call to an action creator into this function. It may look something like this:
dispatch(deleteItem(itemID));
In the example above, the function deleteItem is an action creator (yellow box). Action creators return plain JavaScript objects (orange box). In our case the return object looks like this:
{ type: 'DELETE_ITEM', itemId: itemId }
Note that actions must always have a type property. In this simple example, we could have just passed the plain object in the dispatch function and it would have worked just fine. However, an advantage to using an action creator is that they can be used to transform the data before it’s passed to the reducer. For example, action creators are a great place for API calls, adding timestamps or anything else that may cause side effects.
2. Call to a reducer function Once the action creator returns the action to the dispatch function, the store then calls the reducer function (green box). The store passes two things to the reducer: the current state and the action. An important thing to note is that Redux reducers must be pure functions. Passing the same input to a reducer function should result in exactly the same output every time.
The reducer then computes the next state. It does this using a switch statement that checks for the action type, which in our example case is “DELETE_ITEM”, and then returns the new state. An important point here is that state is immutable in Redux. The state object is never changed directly, but rather a new state object is created and returned based on the specified action passed to the reducer.
In our example this might look like this:
// The code below is part of our itemsReducer. Default state is defined in reducer
// function definition.
switch (action.type) {
case DELETE_ITEM:
return {
...state,
lastItemDeleted: action.itemId
};
default:
return state
}
3. Root reducer combines reducer output into a single state tree A very common pattern is to split your reducer functions into separate files based on what part of the app they address. For example, we might have a file in our hypothetical app called itemsReducer and perhaps another one called usersReducer.
The output of these two reducers will then be combined into a single state object when you call the Redux combineReducers function.
4. The store saves the state returned by the root reducer Finally the store saves the state and all the subscribers (your components) will receive the updated state and the view will be updated as needed.
That’s a general overview of what a Redux architecture looks like. It’s obviously missing many implementation details. To learn more about how Redux works, I highly recommend reading the documentation in its entirety. The Redux docs are very well written and comprehensive. If you prefer video tutorials, Dan Abramov, the creator of Redux, has a couple of great courses here and here.
If you’re interested in getting a quick start playing around with React and Redux, I’ve put together a boilerplate project that’s a good starting point for building a medium-to-large application. It’s informed by many of the lessons learned working on this project.
Until Next Time…
Enterprise applications are often enormously complicated and take the efforts of multiple, dedicated teams each specializing in one area of the application. This is particularly true if the technology stack is diverse. If you’re new to a big project like I’ve described here, try to get a good handle on the architecture, the workflows, the roles of other teams, as well as the tools you are using to build the application. This big picture view will help you produce your best work.
Next week we’ll publish part two, where we’ll go over the build tools and processes we used on the project as well as how we organized the code.