Improving the State of your App with Redux

Redux is a self-proclaimed "predictable state container for JavaScript apps." It has gained popularity through its association and integration with the React library. In this article, we'll look at the basics of Redux architecture and then explore how to build a "real world" application using Redux and Angular 2.

What is Redux

Redux is a lightweight, standalone library that can be used in any JavaScript application that provides a simple but powerful set of features. It can solve many application-specific problems and enable aspects such as:

  • Consistency
  • Testability
  • Logging
  • Hot Reloading
  • Undo capability
  • Record/replay

Redux is designed to provide these features in a pluggable fashion so that the rest of the application doesn't have to change. In fact, once Redux is implemented some developers may build components without ever modifying a line of Redux-specific code.

Redux is especially suited to front-end frameworks because it provides a universal way for components to respond to state without imposing fragile hierarchies. Instead of depending on communication between parent and child controls, components can interact directly with the application's global state, referred to as the "store."

Redux 101

You only need to understand three fundamental concepts to start developing Redux applications.

The Store

The store is your domain. It is an object tree that represents the application state. For example, consider a health-focused app that accepts some basic information and uses well-known equations to compute:

  • BMI – Body Mass Index – a loose indicator of body fat composition based on height and weight;
  • BMR – Basal Metabolic Rate – an estimate of the amount of calories your body burns every day at rest;
  • THR – Target Heart Rate – 50% of your "maximum heart rate" which indicates the start of aerobic exercise, to your max heart rate which indicates extreme, anaerobic activity.

An interface for the store might look like this:

export interface IStats {
    isFemale: boolean;
    isMale: boolean;
    heightInches: number;
    weightPounds: number;
    ageYears: number;
    bmrHistory: number[];
}

And an implementation:

export class Stats implements IStats, IValues {

    public isFemale: boolean;
    public heightInches: number;
    public weightPounds: number;
    public ageYears: number;
    public bmrHistory: number[] = [];

    public get isMale(): boolean {
        return !this.isFemale;
    }
}

The Action

An action is a message that your application dispatches that may potentially change the application's state. For example, the act of updating an age, changing height, or selecting gender are all potential messages that would change the state of the health store.

Here is a simple definition for an action:

export interface IAction {
    type: Actions;
}

export interface IChangeAgeAction extends IAction {
    age: number;
}

And here is a simple function that will create an implementation of the action. With this approach, I can create the action anywhere in my app simply by calling changeAge:

export const changeAge = (age: number) => ({
    type: Actions.ChangeAge,
    age: age
} as IChangeAgeAction);

Actions are dispatched through the store that is provided by Redux. Here is an example of dispatching the message that age has changed:

this.store.dispatch(changeAge(this.age));

This simplifies inter-component communication. Instead of building your components to raise events, you simply dispatch messages to the store. In an Angular 2 app, for example, you could skip exposing an EventEmitter.

Why is this advantageous?

There are many approaches to front-end architecture, and you must decide what works best for you and your team. One approach is to assume components are ignorant of business logic and focus on processing inputs and emitting outputs. Although this allows you to reuse components for different purposes, it also creates coupling between components. If you refactor a component by changing where it "lives" in the tree, you must refactor several other components for the values to propagate correctly.

This is a conceptual diagram of an Angular 2 app. In this app, the InputWidget (part of PageComponentOne) takes a value that is sent to an API by the AppComponent and also updates the OutputWidget (part of PageComponentTwo).

Angular 2 Flow

Another approach is to create specific components that are "business aware." In other words, the component to input an age value takes a dependency on the application's store and emits an age message. Instead of a generic event emitter, it dispatches an event-specific message. Now that component will function the same regardless of where it lives in the display hierarchy. Here is the conceptual flow using a Redux store:

Redux Flow

An action simply indicates something happened. The action helpers you create don't need to have a one-to-one correlation with how the store changes state. For example, in my Redux Adventure app, requesting to move in a direction returns a different action depending on whether the adventurer is likely to run into a wall:

const checkDirection = (dungeon: Dungeon, dir: Directions) => {
    if (dungeon.currentRoom.directions[dir] === null) {
        return {
            type: ACTION_TEXT,
            text: 'You bump into the wall. OUCH!'
        } as IAction;
    }
    return {
        type: ACTION_MOVE, 
        direction: dir,
        newRoom: dungeon.currentRoom.directions[dir]
    } as IRoomAction;
}

As you can see, the action is translated either to a text message indicating the direction is unavailable, or an action message indicating the user is moving.

The Reducer

Now that you know how to dispatch messages, the next step is to interpret how those messages impact the application state. A reducer is a method that transforms the store based on an action. There are two important rules for writing reducers.

  1. A reducer should be a pure function. A pure function always returns the same output when given a specific input and does not generate side effects. It should not interact with any state, entities, or objects that exist outside of that function call.
  2. A reducer should never mutate the state object. It always returns a new object if the state changes.

These rules are important because the benefits of Redux all leverage its consistency and predictability. Violating this will produce unexpected results. For example, although your code will run with this reducer, it is not considered pure:

const changeAge = (state: Stats, newAge: number) => {
  state.ageYears = newAge; // oops, we just mutated the state 
  return state;
}

This is also not considered pure:

const changeAge = (state: Stats, newAge: number) => {
  window.counter++;
  return { ageYears: newAge };
}

So how do we create a pure function? The reducer should reconstruct a brand new state object based on the current state and the action passed. Fortunately, you don't have to manually copy every property because JavaScript has some nice helpers built-in. First, we'll use Object.assign to create a new object and copy over properties. Then we'll take advantage of the object spread operator that is supported by TypeScript to copy values into a new array. Here's what the age reducer looks like now, creating a new object and updating the BMR history:

let newState = new Stats();

case Actions.ChangeAge:
  let ageAction = <IChangeAgeAction><any>action;
  let newStat = Object.assign(newState, state, { ageYears: ageAction.age });
  newStat.bmrHistory = [...state.bmrHistory, newStat.bmr];
  return newStat;

First, a new instance of the Stats object is created. The action is decomposed to get the age information, then Object.assign takes the new object, applies the existing state, then applies the age. Because of the way Object.assign works, the resulting object will have a reference to the existing array for the bmrHistory property. Therefore, adding the new computed Basal Metabolic Rate to the existing array would mutate the state of the existing store. Instead, the property is assigned a new array. The spread operator loads in the existing values, and the new value is added to the end.

Note: Because it is so easy to accidentally create functions that mutate state or have side effects, many developers use libraries like immutable.js to ensure the store is not mutated and leverage Object.freeze for testing.

Now that you've learned about the store, actions, and reducers and have examples. How does Redux fit into a "real" application?

The Redux Health App

To illustrate Redux in a front-end application, I built an Angular 2 app using Redux and the Kendo UI Angular 2 controls. You can see instructions for building the app yourself at the repository and interact with the live demo.

Example App

The application has four components that take input for gender, age, height, and weight, three components that display BMR, BMI, and THR, and a graph that shows BMR history (this is contrived because typically BMR wouldn't change often, but it helps illustrate how Redux works in the app).

You can see the definition for the store, actions, and reducers in the state folder. Creating the store is as simple as calling the function createStore and passing it a reducer. Ignore the "middleware" for now.

Note that everything in the state folder can be built, run and tested completely independent of any front-end framework (with the exception of the Angular 2 test bed that is used to register the tests with Karma). This allows you to build the state and logic for your app independently of the UI. You can explore the various tests for creating actions and reducers and note how the reducer tests leverage Object.freeze (this will throw an exception if the reducer tried to mutate the store state).

Updating State

The HeightInputComponent uses a Kendo UI slider in conjunction with an Angular pipe to display the height and allow the user to change it.

<div>
  <h2>Height:</h2>
  <kendo-slider [min]="12*2" [max]="12*8" [smallStep]="1" 
    [(ngModel)]="height" [vertical]="false" [tickPlacement]="'none'">
  </kendo-slider><span>{{height|heightText}}</span>
</div>

The component very simply sets an initial height based on the store, then dispatches messages whenever the height changes and is in a valid range:

constructor(@Inject(STAT_STORE)private store: Store<Stats>) {}

ngOnInit() {
  this._height = this.store.getState().heightInches;
}

private onChanges() {
  if (this.store.getState().heightInches !== this.height && validHeight(this._height)) {
    this.store.dispatch(changeHeight(this._height));
  }
}

This is very easy to test:

it('should initialize the height', () => {
  expect(component.height).toEqual((<Stats><any>DEFAULT_STAT).heightInches);
});

it('should update height on changes', () => {
  component.height = 60;
  expect(component.height).toEqual(store.getState().heightInches);
});

Although the component takes a dependency on the application state, it does not have to couple to other components or emit any events. All messaging is handled through Redux via the state of the store itself.

Responding to Changes

Of course, the output controls must respond to changes to state. Take a look at the BmiDisplayComponent that uses a pipe to show the BMI level and changes the tile color based on level of risk:

<div [class.obese]="isObese" [class.overweight]="isOverweight" 
  [class.normal]="isNormal" [class.underweight]="isUnderweight">
 <h2>BMI</h2>
 <h3>{{bmi|number}}: {{bmi|bmiText}}</h3>
</div>

The component's code simply subscribes to the store and updates the various flags whenever the state changes:

ngOnInit() {
  this.bmi = this.statStore.getState().bmi;
  this.evaluateBmi();
  this.statStore.subscribe(() => {
    this.bmi = this.statStore.getState().bmi;
    this.evaluateBmi();
  });
}

private evaluateBmi(): void {
  this.isObese = Obese(this.bmi);
  this.isOverweight = !this.isObese && Overweight(this.bmi);
  this.isUnderweight = Underweight(this.bmi);
  this.isNormal = !this.isObese && !this.isOverweight && !this.isUnderweight;
}

Again, this makes it very easy to add new components. The chart component was added later in development, but is completely independent of the other controls and simply subscribes to the state changes like any other control. The subscriptions are also easy to test:

it('should update bmi on changes', () => {
  statStore.dispatch(changeWeight(300));
  expect(component.bmi).toEqual(statStore.getState().bmi);
}); 

When you want to add other components, it's as simple as taking a dependency on the store then publishing changes or subscribing to events.

Middleware

Redux allows you to provide middleware to intercept actions. The middleware can intercept the action and dispatch the same or a different action, and has access to the store. The sample app logs state changes to the console. This is implemented without touching any of the existing components. It's as simple as defining a middleware function that logs the details of the action (you could also dump the state of the entire store if you so desired) and registering it:

export const loggingMiddleware: Middleware =
    (store: Store<Stats>) =>
    (next: Dispatch<Stats>) =>
    (action: Action) => {
        let logDetails = resolver[action.type](action);
        console.log('dispatched: ' + logDetails.description, logDetails.value);
        return next(action);
    };

In this example, I've exported a function to create the store. This is imported by tests and the application to create the store instance. The middleware is added when the store is created. It could inspect a build or environment variable and conditionally inject middleware based on the environment:

export const makeStore = () => createStore(statReducer, applyMiddleware(loggingMiddleware));

There is existing middleware to do everything from logging to recording actions and even integrating with the browser to inspect state as the application is running (take a look at the Redux developer tools).

Final State

This article scratched the surface of what Redux can do. For a more in-depth tutorial of a text-based adventure game with a dungeon map, read An Adventure in Redux. Be sure to check out the formal Redux documentation for advanced concepts, tutorials, and even free videos.

You'll find Redux is a powerful toolset that can transform your approach to building apps. Modern developers complain about "framework fatigue" and so many options for building enterprise apps with JavaScript. Redux is a refreshingly simple, easy library that you can leverage regardless of whether you are using Angular 2, React, Aurelia, or just plain old vanilla JavaScript for your apps.

Related resources:

Comments