Encapsulation in Redux: the Right Way to Write Reusable Components

Redux is one of the most discussed JavaScript front-end technologies. It has defined few paradigms that vastly improve developer efficiency and give us a solid foundation for building apps in a simple yet robust way. No framework is perfect though, and even Redux has its drawbacks.

Redux has the special power make you feel like a super-programmer who can effortlessly write flawless code. Enjoyable though this may be, it's actually a huge issue because it blinds you with its simplicity and may therefore lead to situations where your shiny code turns into an unmaintainable mess. This is especially true for those who are starting a new project without any prior experience with Redux.

Encapsulation Matters

Global application state is arguably the biggest benefit of Redux. On the other hand, it is a bit of a mind-bender because we were always told that global stuff is evil. Not necessarily: there is nothing wrong with keeping the entire application state snapshot in one place, as long as you don't break encapsulation. Unfortunately, with Redux this is all too easy.

Just imagine a simple case where we have a team of two people: Dan and Andre. Both of them were given the very simple task to implement an independent component. Dan is supposed to create Foo, and Andre is supposed to create Bar. Both of the components are similar in that they should contain only a button which acts as a toggle for some string. (Obviously this is an artificial example provided for illustrative purposes.)

Since the state of each Component is distinct, and there are no dependencies between those them, we can encapsulate them simply by using combineReducers. This gives each reducer its own slice of application state, so it can only access the relevant part: fooReducer will get foo and barReducer will get bar. Andre and Dan can work independently, and they can even open-source the components because they are completely self-contained.

But one day, some evil person (from marketing, no doubt) tells Andre that he must extend the app and should embed a Qux component in his Bar. Qux is a simple counter with a button that just increments a number when clicked. So far so good: Andre just needs to embed the Qux in his Bar and provide an isolated application state slice so that Bar does not know anything about the internal implementation of Qux. This ensures that the principle of encapsulation is not violated. The application state shape could look like this:

{
  foo: {
    toggled: false
  },
  bar: {
    toggled: false,
    qux: {
      counter: 0
    }
  }
}

However, there was one more requirement: Andre should also cooperate with Dan, because the counter must be incremented anytime Foo is toggled. Now it's getting more complicated, and it looks like some sort of interface will be needed.

Now we can see the problem with the way Redux blinds developers to potential architectural missteps. People tend to seize on the fact that Redux provides global application state. The simplest solution would therefore be to get rid of combineReducers and provide the entire application state to fooReducer, which can increment counter internally in the bar branch of the app state tree. Naturally this is totally wrong and breaks the principle encapsulation. You never want to do this because the logic hidden behind incrementing the counter may be much more complicated than it seems. As a result, this solution does not scale. Anytime you change the implementation of qux, you'll need to change the implementation of foo as well, which is a maintainability nightmare.

"But wait!" I hear you saying. The whole point of Redux is that you can handle a given action in multiple reducers, right? This suggests that we should handle the TOGGLE_FOO action in quxReducer and increment the counter there. This is a bad idea for a couple of reasons.

For starters, Qux becomes coupled with Foo because it needs to know about its internal details. More importantly, Reto Schl├Ąpfer makes a compelling case that it quickly becomes difficult to reason about code where the results of an action are spread across the codebase. It is much better to compose your reducers so that a higher-level reducer handles each action is a single place and delegates processing to one or more lower-level reducers.

Composition is the New Encapsulation

As you might have spotted, we are suggesting that in addition to composing components and composing state, we should compose reducers as well.

Concretely, this means that Andre needs to define a public interface for his Qux component: an incrementClicks function.

export const incrementClicks = quxState => ({...quxState, clicked: quxState.clicked + 1});  

This implementation is completely encapsulated and is specific to the Qux component. The function is exported because it is part of the public interface. Now we use this function to implement the reducer:

const quxReducer = (quxState = {clicked: 0}, { type }) => {  
  switch (type) {
    case 'QUX_INCREMENT':
      return incrementClicks(quxState); // The public method is called
    default:
      return quxState;
  }
};

Because Bar embeds Qux's application state slice, barReducer is responsible for delegating all actions to quxReducer using straightforward function composition. The barReducer might look something like this:

const barReducer = (barState = {toggled: false}, action) => {  
  const { type } = action;

  switch (type) {
    case 'TOGGLE_BAR':
      return {...barState, toggled: !barState.toggled};
    default:
      return {
        ...barState,
        qux: quxReducer(barState.qux, action) // Reducer composition
      };
  }
};

Now we are ready for some more advanced reducer composition, since we know that Qux should also increment when Foo is toggled. At the same time, we want Foo to be completely unaware of Qux's internals. We can define a top-level reducer that holds state for Foo and Bar and delegates the right portion of the app state to incrementClicks. Only rootReducer, which aggregates fooReducer and barReducer, will be aware of the interdependency between the two.

const rootReducer = (appState = {}, action) => {  
  const { type } = action;

  switch (type) {
    case 'TOGGLE_FOO':
      return {
        ...appState,
        foo: fooReducer(appState.foo, action),
        bar: {...appState.bar, qux: incrementClicks(appState.bar.qux)}
      };

    default:
      return {
        ...appState,
        foo: fooReducer(appState.foo, action),
        bar: barReducer(appState.bar, action)
      };
  }
};

The default case acts exactly like combineReducers. The TOGGLE_FOO handler, on the other hand, glues the reducers together while handling the interdependency. There is still one line which is pretty ugly:

bar: {...appState.bar, qux: incrementClicks(appState.bar.qux)}  

The problem is that this line depends on implementation details of Bar (state shape). We would rather encapsulate these details behind the public interface of the Bar reducer:

export const incrementClicksExposedByBar = barState => ({...barState, qux: incrementClicks(barState.qux)});  

And now it's just a matter of using the public interface:

    ...
    case 'TOGGLE_FOO':
      return {
        ...appState,
        foo: fooReducer(appState.foo, action),
        bar: incrementClicksExposedByBar(appState.bar)
      };

I've prepared a complete working codepen so that you can try this out on your own.

Tomas Weiss

Tomas Weiss

Full-stack JavaScript developer at Salsita