A Complex React/Redux App That I Don't Hate

2023-11-01

In my experience, React/Redux applications often become overwhelmingly complex. This usually stems from the common Redux guideline to "put as much logic as possible in reducers."

The Problem

When logic lives in reducers, we tend to store presentation-ready data in the state. This introduces a major issue: data dependencies. If one piece of data changes (e.g., a user's color selection), related presentation data (e.g., available sizes) might be overwritten or become stale, leading to poor UX like lost user preferences.

Example: The "Bad" Approach

Imagine a reducer that tries to keep size valid whenever colour changes:

/* BAD: Logic inside reducer mixes user intent with presentation rules */
case 'SELECT_COLOUR':
  draft.colour = action.colour;
  // Complex logic to decide if the current size is still valid
  draft.size = getPresentableSize(draft.userSelectedSize, action.colour, action.variations);
  break;

This forces every action to know about dependencies, leading to bloated and fragile reducers.

The Solution

To mitigate this, I recommend a different architecture where we separate data from logic.

1. Reducers as Database Tables

Store only the source of truth—typically raw API responses and direct user input. Do not store derived data.

/* GOOD: Store only what the user actually said */
case 'SELECT_COLOUR':
  draft.colour = action.colour;
  break;
case 'SELECT_SIZE':
  draft.size = action.size;
  break;

2. Service Layer (Selectors)

Move all business logic here. Use generic "selector" functions to derive the presentation data on the fly. Libraries like reselect are perfect for this as they provide memoization.

/* GOOD: Logic lives in a selector */
export const getPresentationSize = (selectedColour, selectedSize, variations) => {
  // Check if the user's selected size exists for the new colour
  if (isSizeAvailable(selectedSize, selectedColour, variations)) {
     return selectedSize;
  }
  // Default to null or apply other business rules
  return null;
}

3. Components as Presentation

Components should only read data derived via selectors. They should never access the raw state directly if that state requires processing.

Implementation Path

If you want to adopt this pattern, follow these guidelines:

  1. Audit your State: Identify what is "User Input" vs "Derived Data".
  2. Normalize Reducers: Refactor reducers to simply store data. Remove conditional logic.
  3. Create Selectors: Build a library of selectors. Start with simple accessors and compose them into complex logic.
  4. Connect Components: Update useSelector calls to use your new derived selectors throughout the app.

Conclusion

By moving logic out of reducers and into a memoized service layer, we can create React/Redux applications that remain maintainable and scalable, even as complexity grows.