Building a state management system around Redux has allowed us to keep the complex codebase of Mapbox Studio (our version of Photoshop for maps) manageable and testable. We’ve used Redux to establish interlocking systems defined by clear, scoped responsibilities and repeatable patterns. We impose restrictions on what code we put where, which make the codebase as a whole more organized, predictable, legible, and testable. These patterns also allow us to increase the quantity of code, as the application grows, without adding complexity to the architecture. (For those new to Redux, this is a must-read 101).

Overview of Studio’s Redux architecture

Our Redux architecture boils down to three highly patterned function types: reducers, action creators, and selectors. After establishing solid conventions and patterns for each type, we can add more functions indefinitely and each addition will fit neatly and predictably into the existing system (and each will be thoroughly testable in isolation).

These function types interact with a few other elements to define the unidirectional flow of information in our application.

Overview of Studio’s Redux architecture

Sub-stores and slice reducers

A core principle of Redux is that there is a “single source of truth” — one store for the application’s state. But we’ve divided that single store, conceptually and practically, into many sub-stores with clear and distinct domains. Each sub-store is updated by its own reducer, which is scoped so it only deals with that particular segment of the total state. In the Redux documentation, such scoped sub-store reducers are called “slice reducers.”

For example, we have a userReducer managing the user sub-store, which contains information about the current user, and a stylesheetReducer managing the stylesheet sub-store, which contains information about the active stylesheet in the map style editor. (We have more than 20 slice reducers in all.) Each of these functions receives only its sub-store’s slice of state as an argument, not the full application state; and the value it returns replaces that sub-store only.

Slice reducers interpret actions to make changes to specific sub-stores within the single Redux store

Each slice reducer ends up looking something like this (using the stylesheet sub-store as an example):

function stylesheetReducer(stylesheetState, action) {
  switch (action.type) {
    case StylesheetConstants.LAYERS_GROUP:
      // ... some state-updating logic that groups layers
      return nextStylesheetState;
    // ... more cases
  }
}

This slice reducer pattern organizes both our code and our thinking. Instead of one vast, unwieldy, and all-powerful reducer function, we have separate and focused reducers, each of which is only allowed to update a specific slice of state.

However, at Studio’s scale even these sub-store reducers can grow pretty large; so we abstract reusable chunks of logic into independent functions outside the reducer. Often, these functions are themselves little reducers — that is, they implement the signature of a Redux reducer. In the map style editor, for example, many updates to the stylesheet sub-store involve modifications to a history object, which allows users to undo, redo, and time-travel through their changes. Our history-updating logic lives in a kind of sub-slice reducer that can be used in response to a number of actions.

function createNewHistoryEntry(stylesheetState, action) {
  // ... some logic that creates a new history entry
  return nextStylesheetState;
}

function stylesheetReducer(stylesheetState, action) {
  switch (action.type) {
    case StylesheetConstants.LAYERS_GROUP:
      // ... some state-updating logic that groups layers
      // then we add a new history entry
      nextStylesheetState = createNewHistoryEntry(stylesheetState, action);
      return nextStylesheetState;
    case StylesheetConstants.LAYERS_UNGROUP:
      // ... some state-updating logic that ungroups layers
      // then we add a new history entry
      nextStylesheetState = createNewHistoryEntry(stylesheetState, action);
      return nextStylesheetState;
    // ... more cases
  }
}

Immutable data structures in stores

Passing state objects around like this can be risky, because it is essential in Redux that you never mutate state. But our Redux store is not a plain old mutable JavaScript object: it’s an Immutable Map. All of the data in the store, from top to bottom, lives in Immutable data structures, and each slice reducer receives and returns an Immutable Map. So we have nothing to fear.

Redux, Immutable, and React fit together wonderfully. It’s important to acknowledge, though, that Immutable introduces a learning curve and some challenges when interoperating with native objects. Still, Immutable has saved us from many mistakes; and it makes shouldComponentUpdate optimization in React a breeze, so for Studio (and probably for other applications of similar size) the added conceptual overhead of Immutable is well worth the benefits.

Action creators and API communication

Action creators manage asynchronous API calls and transform developer intentions into granular state changes.

Studio’s React components invoke action creators to trigger changes to the application state, each change represented by an action. Our action creators are functions named with simple imperative verb phrases, e.g. updateCreditCard, verifyEmail, uploadDataset, dismissWelcomeModal . Often these function names do not correspond to technical plumbing behind the scenes; instead, they describe an intention, a command. The action creator might actually dispatch several actions, depending on what it must accomplish. The point is that it does what you ask it to do.

We encapsulate much of the complexity and business logic of our application in these action creators. They are the powerhouses where developers’ intentions are translated into granular actions that cause all the requisite changes. When invoking an action creator, then, you don’t need to know about everything it will end up doing: you can just invoke the function with the right arguments. If you want to upload a dataset, use uploadDataset; if you want to update a credit card, use updateCreditCard. A component delivers a command, and the action creator takes care of the details.

Often, these details include asynchronous communication with our APIs. In fact, action creators are the only place in Studio’s codebase where we allow ourselves to call APIs! This division of labor has proven invaluable as our codebase has grown: by restricting XHR communication to action creators, we dramatically improve the clarity and testability of the entire codebase.

To manage asynchronous actions, we use redux-thunk, Redux middleware that allows your action creators to return functions, not just action objects. Within these returned functions, you can dispatch multiple action objects over a period of time — for example, one to start a loading process, a few more to indicate progress, and another to end it.

Action creators are always passed into components as props, via the mapDispatchToProps argument of react-redux’s connect(). A typical mapDispatchToProps object simply maps action creators to props of the same name:

const mapDispatchToProps = {
  setFeatures: DatasetActionCreators.setFeatures,
  destroyFeatures: DatasetActionCreators.destroyFeatures,
  setHoveredFeatureIds: DatasetActionCreators.setHoveredFeatureIds,
  saveChanges: DatasetActionCreators.saveChanges
};

One of the most significant benefits of this pattern is that it makes the components easy to unit test. When a function is passed in as a prop (instead of, say, required or imported), a mock can be submitted for that prop in tests. The component doesn’t care if the action creators makes 10 API calls or none, or what logic must be carried out, it just cares that it works. As long as you’ve tested your action creators, you only need to ensure that each component invokes the right action creators, at the right times, with the right arguments.

Selectors

Selectors pass bits of application into React components.

Data from the sub-stores is only accessed by components through selector functions. These are typically named getX, e.g. getSelectedFeatureIds, getDownloadPercentComplete, getActiveLayers, and are invoked within the mapStateToProps argument of react-redux’s connect():

function mapStateToProps(state) {
  return {
    userId: UserSelectors.getId(state),
    ownerId: StylesheetSelectors.getOwnerId(state),
    styleId: StylesheetSelectors.getStyleId(state)
  };
}

Sometimes the selector functions are complex, and sometimes they are trivial, especially because of all the helpers available on Immutable data structures. For example, StylesheetSelectors.getOwnerId might be as simple as state => state.get('ownerId'). However, there are several benefits to always following this pattern, even when it seems silly and you’re inclined not to create a new selector function:

  • You don’t have to decide when to follow it and when to bypass it. Just follow it always. (Also, the readers of your code won’t have to speculate about and second guess your reasons for bypassing the pattern.)
  • Selector functions are easy to systematically unit test, much more so than isolated snippets of logic scattered throughout component code.
  • Selector functions that include some logic (e.g. filtering, mapping, concatenating) can be memoized to avoid unnecessary React re-renders.

Our selector pattern is heavily influenced by the excellent reselect library, which you should definitely consider for your own Redux projects.

Redux helps us code to clear concepts and scalable patterns

Our Redux architecture shows how a set of simple concepts — stores, reducers, action creators, and selectors — can effectively organize large applications. To recap, some of the lessons we‘ve learned working on Studio:

  • As soon as you can identify distinct domains in your state, divide your store into sub-stores by dividing your reducer into slice reducers.
  • Consider using Immutable data structures in your store. This will allow you to pass around state, from root reducers to slice reducers to sub-slice reducers etc., without accidentally mutating anything and causing bugs.
  • Try to isolate network communication in one place, making the rest of your codebase much easier to test.
  • Use redux-thunk or a similar library so you can encapsulate substantial logic and asynchronous processes in action creators, and so any given action creator can dispatch as many actions as necessary to do its job.
  • Use selector functions to pass bits of state to components, and consider using reselect (or some other memoization) to optimize their performance.

If you’re interested in Redux, React, Immutable, and building incredible web applications, the Studio team is hiring!