Taming complexity with Redux

Simen Andresen
ImersoTechBlog
Published in
7 min readApr 22, 2022

This is a part of a series of posts that is intended to describe how we write our software at Imerso. We use them as internal guidelines when writing software, and at the same time, we hope others can make use of it.

Intro

At Imerso, we have a rather large React/Redux code base for our web platform. While our REST API serving the front-end is rather simple (at least by comparison), our front-end is quite complex, with a lot of coupling and cross cutting concerns.

Complexity and Global State

Wikipedia’s article on complexity starts with the following description:

Complexity characterises the behaviour of a system or model whose components interact in multiple ways and follow local rules, meaning there is no reasonable higher instruction to define the various possible interactions.

Although this is a rather general definition of complexity, it applies very well to describe complexity in software. Components (classes, modules, functions etc..) that interact with each other by following a bunch of local rules will add complexity and make everything harder to understand.

React components, by themselves, are often quite simple. They typically have a clear API, stating what data they depend upon, and which events they can trigger. Throw in global state in some way or another (context + useState/useReducer, Redux or your other favorite state management system), and things can quite quickly spiral out of control if you’re not careful. One of the reasons for this is that global state in itself creates a lot of coupling between different parts, making your entire system more interleaved. Some of this coupling is intended, and comes from having business specs that require different parts to have some sort of coupling. However, if you don’t have any reasonable “higher instructions” on how to deal with this inherent complexity, you can easily end up with a solution that adds a lot of extra complexity to an already complex problem.

Although Redux already has quite a clear API providing some sort of “higher instructions”, it also comes with a lot of power and flexibility, which makes it possible end up with inconsistencies and unnecessary complexity

The different layers of our React/Redux application

A good way to deal with complexity is to separate code (and your mental model of the system) into different layers of an application. The benefit of this is that one can limit each layer’s concern, making it easier to reason about.

A high level picture of a React/Redux application typically looks something like this (we have omitted side effects for simplicity):

and can be reasoned about in terms of the different layers:

  • UI layer: Consists of React components. Should typically not know too much about the business logic. However, different components have varying degree of smartness.
  • Business logic layer: Contains a Redux store, which updates state based on user events. The store should know as little as possible about how the data is displayed to the user.
  • Actions: Acts as the interface from the UI layer to the business logic layer, and conveys information about the user events needed to compute the new state.
  • Selectors: Acts as the interface from the business logic layer to the UI. The selectors will typically take a minimal state tree and compute the data that can be used by the UI. Notice that not all Redux state needs to go through an actual memoized selector, using Redux state directly is also fine.

One benefit of this mental model is that it limits the concern of e.g. the Store to worry about the business logic, without having to think too much about how stuff is presented.

In the section below, we’ll show how to achieve a good separation between the layers, by following different recommended best practices.

Best Practices

We assume that the reader is already familiar with the basics of Redux. If you haven’t read the official Redux Style Guide, it’s a good idea to read that first. This section will echo quite a few of the points there already. We will however try to add more real world examples to add motivation to follow the different best practices.

Keep state minimal

Keeping state as minimal as possible is important to avoid hard to catch bugs, and to make updating state easy.

To illustrate this, let’s give an example where we do the opposite:

Let’s say we have a UI that displays a list of buildings, and enables the user to select a building, and edit it. To illustrate the point here, selectedBuilding indicates and holds a copy a building from the buildings array:

In this example, it should be quite obvious what is wrong: we do update the name of the selected building, however, we also keep that same building in an array, which we don’t update. We only changed the name of the building while it was selected, but once deselected, we would display the old name.

Keeping the state minimal would mitigate this problem, since there’s only one place to change the data:

Model actions as events / Put as much logic as possible in reducers

When we started using Redux, we mostly used Redux actions as setters, illustrated with the following example:

What should become evident here is that the reducer is super simple, it simply does what the UI tells it to do. Furthermore, we have some logic in our React component. This is bad for a number of reasons:

  • Splitting the logic between Redux and React makes it harder to understand, since we need to look in both places to get a full overview of what is happening.
  • We don’t respect the concerns of the layers defined above. In this example, the UI needs to know how the store works, and needs to command it on what to do.
  • Looking at the reducer alone, you cannot really tell what is the business logic of it all.

A better approach is to model the actions as events. The mental model here should be that the UI simply tells the business layer what happened, and lets Redux handle the rest. This can be illustrated with a revised version of the above example:

The benefit of this approach:

  • It’s much easier to understand what is going on just by looking at the reducer, and it typically gives code that nicely represents business specs à la: “When clicking on a scan in the list, expand the description”.
  • The different parts are properly separated into layers. The UI merely tells the store what happened and adds the data that is absolutely necessary for the store to carry out the state transition.
  • As much logic as possible is handled in the reducer.

Local state vs. Global State

Using Redux to manage the global state does not mean our React components cannot have their own local state. Local state should be used in the following scenarios:

  • You have state that belongs to a single component, or a very limited component sub-tree.
  • State that represents form data.
  • State that represents simple UI state (e.g. open menu)
  • State that is not coupled to existing global state logic.

The last point here is worth elaborating on. As your application changes, it is quite typical to end up with scenarios where local and global state is coupled in one of the following ways:

Although the openDialog state is only used in one place, it’s still coupled to the global state.

Keeping openDialog as a local state has some disadvantages:

  • It’s harder to obtain a simple mental model of the state updates, where the state changes based on user events, just by looking at the code. Instead, one has to keep in mind how the React component re-renders based on the local and global state.
  • This pattern also encourages modeling actions as setters, since there’s no user event to model the actions from.

Instead, when getting into these scenarios, it’s often better to move the local state into Redux:

Allow Many Reducers to Respond to the Same Action

This is yet another recommendation from the official Redux Style Guide that we found really useful. We’ll illustrate this taking the BuildingsViewer from the previous examples, and expanding it to display a proper table, with table pagination, instead of just showing a list.

Furthermore, let’s say that we have a business spec à la “Changing the table page should deselect the currently selected building”.

To further illustrate the best practice here, we’ll start with a counter-example, where each reducer only responds to actions “owned” by the slice:

The key takeaway from this example is that we dispatch multiple actions for a single event. This has some disadvantages:

  • It is bad for performance, since it can cause multiple, possibly expensive, re-renders.
  • More responsibility is left for the UI layer. It needs to “command” the different slices to do what they should.
  • In the example above, the two slices have some coupling as stated in the spec. However, this coupling is done implicitly through the React component which is the wrong layer to handle these concerns.

The solution to this is to allow the reducers to listen to the same action. With Redux Toolkit, this can be done by using the extraReducers.

We no longer have the disadvantages listed in the counter example. Only a single action is dispatched, and perhaps more importantly, it’s much easier to see what is going on just by looking at the reducers — the coupling between the two slices should now be much clearer, since it’s “described” in the extraReducers method.

Conclusion

Hope you found this helpful. Redux is quite powerful and easy to get started with. And while your app is small, you’ll probably get away with not being too strict about how you use it. However, as the application scales, bad practices will easily blow up into code that is hard to understand, hard to modify and hard to extend.

--

--

Simen Andresen
ImersoTechBlog

Co-founder, CTO and full-stack developer at Imerso. Working with 3D scanning and quality control solutions for the construction industry.