Company Blog

On Client Application State Management Using Flux and React

Five months ago, our newly formed team inherited a product (QCloud) built by an entirely different set of people

There were some uncertainties at the time regarding what we should do with the codebase, but one thing we knew we had to do was to migrate off of the original client-side stack of Backbone and Marionette.

Why?

For us, the issue with Backbone (or just about every MV* frameworks) is that state management is very complex in our client application. This complexity leads to a two main problems.

  • It is hard to reason about how encapsulated, local states affect overall application behaviour. This means that modifying objects in the system becomes risky because we can’t reasonably test all permutations of object interactions in the system.
  • When the application gets into a bad state, it is hard to know the sequence of interactions that lead it. As the application accumulates more and more features, it also will become harder and harder to debug it.

In this post, we will discuss how state management is done in our Flux architecture and why you might consider this approach.


The hard thing about state management

Let’s talk about the server-side a bit shall we? Here’s a typical request lifecycle.

  • The request is routed to a controller.
  • The controller reads in the necessary parameters from the request.
  • The controller loads some data.
  • The controller updates a global state (the database).
  • A view is rendered based on the updated state.

Notice how simple that is? The request is stateless – that is, the server does not retain knowledge of any of the previous requests. All updates are written to a global state (the database). The response is just a pure render of the new state.

Now contrast this with the client-side and what are we presented with?

  • Encapsulated local states everywhere.
  • Impure views that have many side effects.
on-client-application-state-management-using-flux-and-react-2

In the diagram above, the yellow boxes represent objects that have encapsulated states that change over time and affect our application’s behaviour.

By pure renders I’m referring to pure render functions. That is, the return value of the render function is determined only by its inputs, without observable side effects. Backbone views are not pure because view.render() takes no input and yet can generate different outputs.

This impurity is largely the result of encapsulated local states.


Local states add complexity

A client application has a much longer lifecycle than one HTTP request. If all the objects in the system maintains their own state and can affect one another, then the system as a whole is very hard to reason about.

How many times have you closed a bug report with Cannot reproduce? It’s not that we don’t believe that the reported bug is real, we just cannot feasibly go through all the permutations of interaction points and states.

Mutable objects are dangerous, side effects are bad, and yet without them our application is useless. While we cannot avoid side effects, we can certainly limit the areas in which they can occur in the system.

In our Flux architecture, states and mutations are limited to stores. On top of that, stores cannot affect each other. This reduces the number of objects we need to account for when reasoning about the application state.

When we receive a bug report, we can easily replay the recorded actions against the store in question. There’s no mucking around in the UI trying to reproduce the bug, we can tackle it by writing a new store test using the recorded actions.

Looking back at the server-side comparison, actions within our Flux architecture is analogous to a stateless HTTP request. Any mutations that occur in the system is reflected in our global stores, just as updates caused by HTTP requests are written to the database.


Keeping state out of components

A really important idea we embraced is to write pure React components. We do not use state in our components, and instead the components are passed inputs via props.

For example, our QCloud notifications component is as follows.

React.createClass({
  propTypes: {
    notifications: array.isRequired,
    onDismiss: func.isRequired
  },

  render() {
    return (
      <div className="notifications">
        {this.props.notifications.map(n => (
          <Notification key={n.id}
                        notification={n}
                        onDismiss={() => this.props.onDismiss(n)} />
        ))}
      </div>
    );
  }
}

Here, the notifications are passed as an array to the Notifications component. When a user dismisses a notification the system is notified via the onDismiss callback, which is resolved by triggering a NOTIFICATION_DISMISSED Action.

When this Action is dispatched, the NotificationStore can update its state by removing the dismissed notification. The component is then passed the new notifications array and re-renders. This re-render is cheap because the component renders to the virtual DOM and React computes the minimal set of manipulations required to update the real DOM.

Note that the render and action are segregated completely. This gives us a lot of flexibility in the system. If we need to add business logic, we can usually do so without modifying our component.

Say we want to give the server the ability to reject the dismissal. This can be done by adding a new NOTIFICATION_DISMISS_REJECTED action and extending our NotificationStore to respond to it. Our Notifications component is left untouched.

Now, let’s take a closer look at actions and how they affect application state.


It’s all about data flow, not model interactions

When a user is using the application, they are not thinking about manipulating models in the system. They think in terms of actions they are performing in the system.

For example, in QCloud, inspectors perform quality checks by submitting inspections. These inspections are then reviewed by a manager. From the manager isn’t thinking about Inspection models or InspectionReview models. What managers care about is that they viewed a submitted inspection and then submitted their review.

In a Flux architecture, you are naturally lead to think really hard about the actions that occur in your system. In the inspection review scenario there are two actions:

  • Viewing a submitted inspection and
  • Submitting a review of the inspection.

From these two actions we now need to think about how they translate into application state changes.


Store state mutations

When an action is dispatched in the system, all of the registered stores are passed the action ID and a payload.

For example, in the inspection example our store may look as follows.

class SubmittedInspectionStore extends EventEmitter {
  constructor(dispatcher) {
    this.state = { inspections: [] };

    dispatcher.register((actionId, payload) => {
      switch actionId {
        case 'INSPECTIONS_LOADED':
          this.state.inspections = payload.inspections;
          this.emit('change');
          break;

        case 'INSPECTION_REVIEWED':
          const { inspection, reviewNotes, reviewedBy } = payload;
          this.state.inspections = this.state.inspections.map(i => {
            if (i.id === inspection.id) {
              // Set reviewe=true, and store additional details.
              return Object.assign({}, i, {
                reviewed: true,
                reviewNotes,
                reviewedBy
              });
            } else {
              return i;
            }
          });

          this.emit('change');

          break;
      }
    });
  }
}

When the store is updated, the new state is passed down to components that consume this data, potentially resulting in a render. This forces us to think about the complete data flow of action -> store -> component.


Projecting new states

There are cases where we want to project new information from existing actions. In Flux, this is very simple to do!

For example, if I want to keep a counter of how many inspections are reviewed, I can add a new store to track this information.

class ReviewedInspectionsCountStore extends EventEmitter {
  constructor(dispatcher) {
    this.state = {};

    dispatcher.register((actionId, payload) => {
      const prevState = this.state;

      // Call the reduce function and get the next state.
      this.state = this.reduce(this.state, actionId, payload);

      // If our state has changed, trigger update.
      if (prevState !== this.state) {
        this.emit('change');
      }
    });
  }

  reduce(prevState, actionId, payload) => {
      switch actionId {
        case 'INSPECTIONS_LOADED':
          return {
            count: payload.inspections.reduce((count, inspection) => {
              if (inspection.reviewed) {
                return count + 1;
              } else {
                return count;
              }
            }, 0);
          }
          break;

        case 'INSPECTIONED_REVIEWED':
          return {count: prevState.count + 1};
          break;
      }
    });
  }
}

This new store can now be plugged into the system and we’re good to go! We did not have add new models, new object roles, or modify existing objects. That means new information can be created without affecting the rest of the system.

As a bonus, testing our application state can be done without rendering any component.

describe(ReviewedInspectionsCountStore, () => {
  it('should keep count of the number of reviewed inspections', () => {
    const dispatcher = {
      register() {}
    };
    const store = new ReviewedInspectionsCountStore(dispatcher);

    let state = {};

    state = store.reduce(state, 'INSPECTIONS_LOADED', [
      {id: 1, reviewed: false}, {id:2, reviewed: true}
    ]);
    expect(state).to.eql({count: 1});

    state = store.reduce(state, 'INSPECTION_REVIEWED', {id: 1});
    expect(state).to.eql({count: 2});

    // This test will fail as a result of a bug. Can you spot it?
    state = store.reduce(state, 'INSPECTION_REVIEWED', {id: 1});
    expect(state).to.eql({count: 2});
  });
});

The difference between unit testing stores and unit testing models is that store tests are more representative of our real application interactions and state.


Painless debugging in production

Now that our application state is captured completely within stores, we can take advantage of this when we encounter production bugs.

For QCloud, we added a tool that records actions within the system so that we can play them back to check our store states.

on-client-application-state-management-using-flux-and-react-3

We have enabled it in our development and testing environments. This means that when someone finds a bug in the application, we have access to the actions that occurred to get to the bad state.

This allows us to copy the generated test code, play it against a store, and assert the state of that store. If the test fails, we now have a new test that captures a real use-case. Once the test is fixed, we can patch the application!


Do we always avoid encapsulated states?

While I do think that a shared, global state simplifies the application, it is by no means the one way to build applications. There are situations that warrant using encapsulated components states.

For example, we may want to hide some implementation details of a component from the rest of the application by manipulating the local state, and only notifying the system when the change needs to be flushed. We ran into this situation when a component’s onChange callback triggered too many actions in the application, leading to performance degradation on slower mobile devices. We ended up storing the value changes in the component’s state, and calling onChange when the component loses focus. This change was an optimization step, and definitely would not have been done if we did not run into performance issues.

Whatever the reason you have for using encapsulated states, make sure the states have no significant impact on the application. If a component’s state is long lived, then this encapsulation may lead to hard-to-debug problems later on.

The default should be writing to a global application state, and falling back to encapsulated state in exceptional cases.


Summary

  • State management can be very hard in a client application.
  • We can simplify our application by limiting states and mutations to our stores.
  • We should put more focus on our application’s data flow rather than model interactions.
  • We can make debugging much easier by recording the actions in our application.
  • There are cases for using encapsulated component states, but should be limited to special cases.