DEV Community

Yiannis Nennes
Yiannis Nennes

Posted on • Updated on

A complex React/Redux app that I don't hate

Motivation

In the last years I worked on a few React/Redux applications. Often we found ourselves maintaining an application that became overwhelmingly complex over time. This post describes some of the ways this can happen and the approach I'm currently following, which solves many of the issues. I hope this will be an interesting read that may help remove some of the complexities in your own apps.

TL;DR Proposed architecture

Our aim is to make the application more maintainable and in the process enable better UX.

Key ideas:

Treat reducer logic is as an anti-pattern.
Derived values should not be materialised in the store.
No direct access to the store from the components.

Implementation:

  • Think of reducers as database tables. No logic, just plain storage. Only store the source of truth, which is raw API responses and user selections.
  • Introduce a service layer for all the business logic. My personal favourite for implementing this layer is reselect, which is a library for creating memoized "selector" functions. It allows us to combine multiple small functions that apply business rules to raw data from the store and surface the right presentation-ready values.
  • Treat components as presentation only code. The presentation data should be derived from the store via the service layer.

Introduction

React/Redux is a common framework for writing complex applications. It's frequently suggested that Redux is an overkill for simple apps. This implies that a complex app is where Redux shines. However, as many engineers have experienced, the Redux side can quickly become too challenging to maintain, even on a moderately complex app.

In my experience, one of the biggest difficulties when using Redux is adding logic inside the reducers without introducing complexity. According to the Redux guidelines, one should put as much logic as possible inside the reducers. This creates a challenge: Individual reducers cannot directly access the global state, so complex logic that involves multiple reducers quickly becomes cumbersome to manage.

A common way around this is to create a few large reducers, so that necessary data within each reducer is more likely to be readily available. This is however a trap; bigger reducers often have to handle more actions, and for each action we need to update a bigger state object. Introducing a new action requires understanding how the other actions in the reducer modify the state and leads to additional logic to make sure that the state is updated correctly.

If instead we choose to implement many small reducers, they will require extra information passed in with each action (via the payload). To support this, actions have to become async, so that they can access the entire state and pass the required information to the reducers. If multiple reducers listen to an action and each reducer requires different data, we are now faced with big action methods that have to pass large amounts of data around to support each reducer that listens to them.

Finally, putting as much logic as possible inside the reducers implies that they should store all the data that's required by the components. In practice, this seems to encourage storing presentation-ready data in the state. This does make the component logic simpler (at the expense of reducer logic), but introduces another issue: If display data needs to be automatically updated, for example due to validations or business rules, the user selections may be overwritten. Let's present an example that shows how this is a problem, using the following interaction in a physical store:

Customer: I would like this shirt in red.

Salesperson: No problem. We have it in all sizes (S-XL). What's your size?

Customer: My size is medium.

Salesperson: Great!

Customer: Actually, can I have it in green?

Salesperson: Green is only available in small unfortunately.

Customer: OK, can I have it in orange then?

Salesperson: We have orange in all sizes (S-XL). What's your size?

This is a super simple scenario and even a junior salesperson should have remembered that the customer wants medium size. Unfortunately, our application that stores presentation data in the state is losing the customer preference, leading to poor UX. But don't feel too bad, a trillion dollar online retailer (that sounds like a rainforest) gives us the above experience as well. :)

Demo of the problem

Let's assume that we have a retail store application written in React/Redux. We save the selected options in a reducer and use it to update the various parts of the screen. Let's emulate the previous dialog:

    selectedOptions: {
        colour: null,
        size: null
    }
Enter fullscreen mode Exit fullscreen mode

Customer: I would like this shirt in red.

Salesperson: No problem. We have it in all sizes (S-XL). What's your size?

    selectedOptions: {
        colour: 'Red',
        size: null
    }
Enter fullscreen mode Exit fullscreen mode

Customer: My size is medium.

Salesperson: Great!

    selectedOptions: {
        colour: 'Red',
        size: 'M'
    }
Enter fullscreen mode Exit fullscreen mode

Customer: Actually, can I have it in green?

Salesperson: Green is only available in small unfortunately.

    selectedOptions: {
        colour: 'Green',
        size: null  // 'M' is not available in Green
    }
Enter fullscreen mode Exit fullscreen mode

Customer: OK, can I have it in orange then?

Salesperson: We have orange in all sizes (S-XL). What's your size?

    selectedOptions: {
        colour: 'Orange',
        size: null // initial user preference of 'M' cleared
    }
Enter fullscreen mode Exit fullscreen mode

This example demonstrates how storing presentation data in the reducer means that the user is forced to select their size preference again. One lost preference may not be that bad, but consider the UX impact were we to reset 5 or 10 user selections.

One workaround would be to not only store the current presentation values (colour: 'Green', size: null), but also the user's own size preference (size: 'M'). Then, we would need to introduce logic in the reducer that calculates the right presentation size value ('M' or null), depending on the current colour preference (and potentially other bits of info in the state). Such an implementation is shown below:

export const INITIAL_STATE = {
  colour: null,
  size: null,
  userSelectedSize: null
}

const getPresentableSize = (userSelectedSize, newColour, variations) => {
  const availableSizesForColour = variations
    .filter(v => v.colour === newColour)
    .map(v => v.size)

  if (availableSizesForColour.includes(userSelectedSize)) {
    return userSelectedSize
  }

  return null // or apply logic to generate some default value
}

const selectedOptionsReducer = (state = INITIAL_STATE, action) => {
  return produce(state, draft => {
    switch (action.type) {
      case 'SELECT_COLOUR':
        draft.colour = action.colour
        draft.size = getPresentableSize(draft.userSelectedSize, 
          action.colour, 
          action.variations
        )
        break

      case 'SELECT_SIZE':
        draft.userSelectedSize = action.size
        draft.size = getPresentableSize(action.size, 
          draft.colour, 
          action.variations
        )
        break
    }
  })
}

export default selectedOptionsReducer
Enter fullscreen mode Exit fullscreen mode

The problems become immediately visible:

  • All actions must carry extra data, so that the business logic inside the reducer can produce the right presentation values.
  • Actions unrelated to the dependent property (size) must update it, in case the presentation value needs to change.
  • size is a presentation safe value, userSelectedSize is not. A component can easily use the wrong property (userSelectedSize instead of size) and introduce a bug (userSelectedSize does not hold presentable data).

The reader can imagine the mayhem of complexity if we expand our app and introduce:

  • Complex business logic and multiple edge cases.
  • Multiple properties that need to be automatically recalculated.
  • A large state with complex objects that need to be rebuilt for every action.
  • A large number of actions in the reducer.

In my experience, such a reducer would need thousands of LOC in tests just to describe each complex scenario and is well on its way to becoming buggy and unmaintainable.

Demo of the proposed solution

We would like to structure our application in a way that achieves the following:

  • Code should be easy to read and understand
  • It should be easily amendable without introducing unexpected side effects.
  • Adding localised business logic should not require changes across unrelated areas.
  • We should never lose information from the store that can be useful in the future.

With the new approach, the reducer updates should modify the store like this:

    selectedOptions: {
        colour: 'Red',
        size: 'M'
    }
Enter fullscreen mode Exit fullscreen mode
    selectedOptions: {
        colour: 'Green',
        size: 'M'
    }
Enter fullscreen mode Exit fullscreen mode
    selectedOptions: {
        colour: 'Orange',
        size: 'M'
    }
Enter fullscreen mode Exit fullscreen mode

Now the store data cannot be used directly to provide presentation values and instead need a separate (service) layer. To get the right presentation value of size, we require a helper method (selector) that looks similar to getPresentationSize:


const isSizeAvailable = (size, colour, variations) => {
  const availableSizesForColour = variations
    .filter(v => v.colour === colour)
    .map(v => v.size)

  return availableSizesForColour.includes(userSelectedSize)
}

export const getPresentationSize = (
    selectedColour, 
    selectedSize,
    variations
) => {
    if (isSizeAvailable(selectedSize, selectedColour, variations)) {
        return selectedSize
    }
    return null // or apply logic to generate some default value
} 
Enter fullscreen mode Exit fullscreen mode

This implementation is pretty much identical to the one in the "problem" scenario; we basically moved logic from the store to the service layer. However, we have achieved the following:

  • Updating the store does not require extra logic to keep the "presentation" properties valid.
  • Calling this method is guaranteed to provide the right value. No need to care about the store at all, it's completely hidden.
  • We get default values for free: Missing / invalid user selection always leads to sensible defaults that rely on the current state of the application. In the previous implementation we could need to materialise those defaults; to achieve the same result we'd need to update those properties for every action under the sun.

That sounds a lot like MVC

The proposed separation of concerns is shown in the following diagram:

Diagram with MVC layers

We're slowly evolving towards an MVC-style pattern, where the raw (non-derived) data lives in Redux, pure presentation lives in React and in the middle we have our service layer. The first benefit to this approach is that unlike reducer logic, our service layer has access to the entire store. Using reselect for the service layer is a great option, as we get composability and memoization for free. Composable selectors allow for building super complex logic by re-using other selectors as "building blocks". Imagine writing a method that gives you very high level information (e.g. order cost breakdown), which reads like this:

const getTotalCostBreakdown = (store) =>
    [
        ...getSelectedVariations(store),
        ...getAdditionalOptions(store),
        ...getDiscounts(store)
    ]
Enter fullscreen mode Exit fullscreen mode

Each one of those method calls represents a potentially huge tree of nested method calls. Each one of the nested method calls includes appropriate business logic, validation and default values. And given that selectors are memoized, it would all run in O(n), where n is the total number of methods. There is no performance impact from the nested calls and we're guaranteed to respect all business rules at every level (DRY), while keeping each method easily readable.

Downsides

  • We're introducing an extra level of indirectness on top of Redux. More code means higher cognitive load and bigger bundles. Even determining if there is a selector for the data I want can be painful.
  • Some values in the store are safe to use for presentation and some may be not. We don't have language / framework protection against using the unsafe ones, anyone can read the wrong data. Scary comments and naming help but it's obviously not ideal. Creating a "hard rule" that only selectors can read from the store reduces the surface of the issue, but increases the amount of selectors.
  • In order to get composable methods, we have to pass lots of data to methods that don't directly need them. In practice we pass the whole store to every method. This is convenient but it's also an anti-pattern. Reselect addresses this by calling other selectors outside of the current selector body, therefore preventing direct access to the entire store.
  • If we need the entire store to call any selector, what if I need some information before the store has been fully populated, for example to build an api call request? Hopefully the initial values are good enough. If not, we can try to execute this code in the "right order", which isn't ideal. The proposed pattern makes this problem worse, because we have no clear view of what data a selector is using. You shouldn't encounter this often though.
  • It's easy to fall into the trap of putting everything into a selector. For simple logic that's not shared, consider keeping it in the component.

Guidelines

If your team would like to try this out, everyone needs to follow some basic guidelines. These are summarised below:

  • Clear separation of concerns

    • Redux store only saves 2 kinds of information:
      • Network responses
      • User interactions
  • All business logic calculated in selectors.

  • Connected components should not read from the store directly; only from selectors.

  • Very little logic in React components, only what is necessary to render this component and is not impacting other parts of the application.

Conclusion

I have used this pattern both in the FE (React) and the BE (Spring Boot) and it worked very well in both cases. In my case it provided a clear mental model and a maintainable codebase. If you're encountering some of the issues mentioned above, consider giving it a go. It can definitely lead to a maintainable and reliable application!

Top comments (2)

Collapse
 
hsyed profile image
Hassan Syed • Edited

The "physical store" scenario is quite appropriate but the insight of what the problem is could be clearer. I think some discourse arround what it looks like in react terms would make things clearer. Specifically that an API call resulted in a comprehensive stock listing which the store is using and also showing the psuedo-state of the store in the discussion.

Collapse
 
nennes profile image
Yiannis Nennes

Thanks for the feedback Hassan, much appreciated. I've added some code snippets but it's probably better to make a small app that showcases this pattern. Will update the post with links to the app soon.