Coding For Complexity

December 2, 2019

How state machines make it easier to reason about complex application state.

Writing Good Code

While the details of writing good code are highly subjective, a good basic rule is to try and optimize your implementations for correctness and clarity.

The code needs to accomplish the task for which it was written, and it needs to be done in a way that is straightforward, so that it can be effectively refactored, shared and extended.

We spend a fair amount of our lives as developers trying to prove to ourselves that our code respects these two tenants.

We write tests and run linters to provide confidence in our code’s correctness and sprinkle comments and documentation in our implementations to help others (and often our forgetful selves) understand the more complicated or hacky pieces that make up our applications.

It has been said in the programming community that:

Shared mutable state is the root of all evil.

If that’s the case, then the application state we write, refactor, and share with other programmers every day makes myself and most other developers very evil people.

But of course that’s not the case, and in fact, there are many things we can do to make the shared mutable states of our applications clearer and more correct. Let’s start with a simple example:

How many states does this code have?

if (enabled) {

} else {
    
}

You: 2!
Me: You’re right!

How about this code?

if (enabled && !eligible) {
} else if (eligible) {
}

You: 4?
Me: Right again!

But how many of those states are we explicitly handling? Only 2. What happens in those other 2 cases? Usually nothing, but potentially errors that we haven’t accounted for, or that we’ll uncover when we add new functionality.

This is an example of how from the basic conditionals we first learn to the complex systems we spend hours reviewing and testing, we are programmed to program for happy paths.

Transitioning to state machines

Without a structured approach to iterating the states our code can be in it can be very difficult to make sure that you have all of your bases covered. I’d argue that most, if not all developers have stumbled upon routines consisting of questionably named flag variables used in convoluted combinations whose author is either long gone or too promoted to ask anymore.

So what techniques can we use to help us structure clear and correct application state? One approach our team has tried in the past is to use state machines.

state-machine

The concept of a state machine is fairly simple. While combinations of conditionals create inexhaustive, implicit applications states, state machines require explicit and comprehensive definitions of all the application states and valid transitions between them in a system.

Our team’s first state machine used the javascript state machine (JSM) library to define our application’s authentication process. For a React-Native application like ours, that involved managing a lot of different application states ranging from account registration and sign-in, to biometrics, deep-linking, backgrounding, and offline support, not to mention the nuances of the respective iOS and Android platforms.

Our previous authentication flow had consisted of a lot of nested conditionals and long function bodies and comparatively our state machine approach had some immediate advantages:

  1. The diagrams of our authentication state machine generated by the library helped us better document and reason about our implementation.
  2. Dividing the process into a series of states and transitions led to clearer, more organized code.
  3. State machines have their own data store, allowing us to encapsulate authentication details within our state machine instead of our global Redux store.

Our state machine configurations looked something like this:

const auth = new StateMachine({
  init: 'UNAUTHENTICATED',
  data: {},
  states: {
    AUTHENTICATED: { name: 'AUTHENTICATED' },
    UNAUTHENTICATED: { name: 'UNAUTHENTICATED' },
    SIGN_IN: { name: 'SIGN_IN' },
    REGISTER: { name: 'REGISTER' },
    ...
  },
  transitions: [
    { name: 'signout' from: 'AUTHENTICATED', to: 'UNAUTHENTICATED' },
    { name: 'signin' from: 'SIGN_IN', to: 'AUTHENTICATED' },
    ...
  ],
  methods: {
    onEnterAuthenticated: () => doX(),
    onEnterSignIn: () => showSignInScreen(),
    ...
  }
});
auth.transition('signin');

The JSM configuration consumes a set of states and transitions, as well as lifecycle handlers for entering, leaving and transitioning between states. Each step in our authentication system received its own state and the business logic we need to perform was executed in the onEnterState lifecycle handlers.

While it was definitely an improvement from our previous implementation, over time as we modified and added new steps to the authentication process it once again became difficult to work with and we had to overcome a new set of problems inherent to state machines:

Authenticated/Foregrounded Unauthenticated/Backgrounded
Authenticated/Backgrounded Unauthenticated/Backgrounded

Enter statecharts

statecharts are a layer on top of state machines that enhance the core concept of a graph-based definition of system states with additional features and patterns for managing complexity.

While the JavaScript community is known for being quick to embrace new and unproven technologies, a formalization for the statecharts model was introduced decades ago in this public paper and the system has already applied in a number of real-world scenarios:

When NASA used the Statechart Autocoder to build the software for the Mars Science Laboratory (the Curiosity Rover), one of the reported benefits of using Statecharts was that “it forced developers to consider off nominal scenarios”.

Whether you call them bugs, features, or off nominal scenarios, statecharts can help developers identify edge cases and write clearer state machine implementations.

Before we implement a statechart, we’ll need to become familiar with some of its core terminology:

Hierarchical states

statecharts address the problem of poor-encapsulation by supporting hierarchies of states.

A compound state like a user’s activity in a form can be broken down into a number of substates like name, birthdate, address and others.

state-machine-nested

Parallel states

To address the state explosion issue, statecharts support parallel states. A parallel state node is in all of its child states at the same time.

A system of boolean conditions like authenticated/unauthenticated and foregrounded/backgrounded will now grow linearly as opposed to exponentially and can better represent the state of the system, since a user can be both authenticated and backgrounded simultaneously.

Chart visualizations

One of the biggest advantages of the statechart model is its translation into a clear and formalized visual statechart diagram:

state-machine-example

The diagrams follows the W3C statechart XML (SCXML) implementation and can clearly illustrate every part of a state machine configuration including parallel and hierarchical states.

statecharts in action: xstate

xstate is a JavaScript implementation of the statechart model. It describes itself as:

A library for creating, interpreting, and executing finite state machines and statecharts, as well as managing invocations of those machines as actors.

xstate implements the statechart model with all of its core feature specifications:

It additionally models each of its state machine objects as actors. The actor model is a model of concurrent computation that treats “actors” as the universal primitives of a system, with each actor able to perform three core functions:

Each actor encapsulates its own logic and exchanges information with other state machines through events. In this way, an active state machine can be thought of similarly to a process running on your system, unable to access data from other services directly but able to share input and output between them.

Let’s see how xstate can be used to model one of our actual button components. Our example code will be using React, but because its implementation is self-contained, it can be reused in your frontend framework of choice.

const Button = ({
  onPress,
  enabled,
  isLoading,
}) => {
  return (
    <div
      className={css(getContainerStyle(enabled).container)}
      onPress={() => onPress()} // Optimizing here for example lines not performance
      disabled={!enabled || !onPress || isLoading}
    >
      {
        isLoading ? 'Loading...' : 'Submit'
      }
    </div>
  );
};

The button component has 3 inputs:

  1. onPress: Controls what happens when it’s clicked.
  2. enabled: Manually override the enabled/disabled state of the button.
  3. isLoading: Choose whether the button should display a loading state.

It consumes these inputs to determine which one of the three mutually exclusive UI states to display: loading, enabled or disabled.

We write conditional logic like this all the time to model implicit state machines. While this approach generally works well as complexity remains low, even in a relatively simple button component we can identify some edge cases:

  1. The button can become loading at the same time it is disabled.
  2. The button can become disabled at the same time it is loading.
  3. The style of the button can be changed at any time, even if loading or unhandled.

Now let’s compare this to an explicit state machine model using xstate. We’ll start with the state machine definition:

const transitions = [
  { target: "#button.enabled", cond: (context, event) => !event.isLoading && event.onPress && event.enabled },
  { target: "#button.disabled.loading", cond: (context, event) => event.isLoading },
  { target: "#button.disabled.manual", cond: (context, event) => !event.enabled },
  { target: "#button.disabled.unhandled", cond: (context, event) => !event.onPress }
];

const ButtonMachine = Machine({
  id: "button",
  initial: "enabled",
  states: {
    enabled: {
      on: {
        UPDATE: transitions
      }
    },
    disabled: {
      id: "disabled",
      initial: "init",
      states: {
        init: {},
        loading: {},
        manual: {},
        unhandled: {}
      },
      on: {
        UPDATE: transitions
      }
    }
  }
});

const { initialState } = ButtonMachine;

console.log(initialState.value);
// => 'enabled'

const nextState = ButtonMachine.transition(initialState, 'UPDATE', { isLoading: true });
console.log(nextState.value); // => disabled.loading

This configuration sets up an atomic enabled state and a compound disabled state. On receiving an UPDATE event, the state machine goes through the transitions array until it finds the first transition that satisfies its condition and executes that transition.

The transition API is a pure function which takes an initial state and an event and returns the new state of the system. While a state machine with a pure .transition() function is useful for flexibility, purity, and testability, in order for it to have any use in a real-life application, something needs to:

An interpreter is responsible for interpreting the state machine/statechart and doing all of the above - that is, parsing and executing it in a runtime environment. An interpreted, running instance of a statechart is called a service.

Now that our conditional logic has been abstracted, we can focus on writing the plain presentational layer:

const Button = ({
  onPress,
  enabled,
  isLoading,
}) => {
  const [current, send] = useMachine(ButtonMachine);
  const disabledState = _.get(current, 'value.disabled');
  const isEnabled = _.get(current, 'value') === enabled;

  useEffect(() => {
    send("UPDATE", { onPress, enabled, isLoading });
  }, [enabled, isLoading, send, onPress])

  return (
    <div
      className={css(getContainerStyle(isEnabled).container)}
      onClick={() => onPress()}
      disabled={!!disabledState}
    >
      {disabledState === "loading" ? "Loading..." : "Submit"}
    </div>
  );
}

The useMachine API from @xstate/react interprets the given state machine and starts it as a service:

import { interpret } from 'xstate'
import { useRef, useState } from 'react';

export const useMachine = stateMachine => {
  const [state, setState] = useState(stateMachine.initialState);
  const ref = useRef(null);

  if (ref.current === null) {
    ref.current = interpret(stateMachine);
  }

  useEffect(() => {
    if (!ref.current) {
      return;
    }

    ref.current.subscribe(setState);
    ref.current.start();

    return () => {
      ref.current!.stop();
      ref.current = null;
    };
  }, [stateMachine]);
}

When we receive new props in our component, we send them to the running state machine service as an UPDATE event.

This explicit state machine has three main benefits over our first implementation:

  1. The button can now only be in one state at a time (enabled, disabled.loading, disabled.manual or disabled.unhandled)
  2. There is a priority to its states: If it is in the loading state and it receives a new manual enabled=false prop change, it will not transition from the loading state.
  3. The state machine can be visualized according to the statechart specification.

Here is a diagram of our button state machine:

xstate button

The ability to visualize the states of a UI component or experience can benefit the entire team.

Modeling Flows

statecharts are also useful tools for managing complex flows. For our development focusing on financial products, an example of such a flow could be an application for a new bank account.

This financial product will consist of a number of screens the user goes through to enter their personal information required for the application:

import { Machine, assign } from 'xstate';

const BankApplicationMachine = Machine(
  {
    initial: 'NAME',
    context: {
      name: null,
      dateOfBirth: null,
      SSN: null,
      address: null,
    },
    states: {
      NAME: {
        entry: 'showNameScreen',
        on: {
          SUBMIT: {
            target: 'DATE_OF_BIRTH',
            actions: assign((context, { name }) => ({
              ...context,
              name,
            }))
          }
        }
      },
      DATE_OF_BIRTH: {
        entry: 'showDOBScreen',
        on: {
          SUBMIT: {
            target: 'SSN',
            actions: [assign((context, { dob }) => ({
              ...context,
              dob,
            }))]
          }
        },
      },
      SSN: {
        entry: 'showSSNScreen',
        on: {
          SUBMIT: {
            target: 'ADDRESS',
            actions: [
              (context, { ssn }) => assign({ ssn }),
            ],
          }
        }
      },
      ADDRESS: {
        entry: 'showAddressScreen',
      }
    },
  },
  {
    actions: {
      showNameScreen: (context, event) => Navigation.push('NameScreen'),
      showDOBScreen: (context, event) => Navigation.push('NameScreen'),
      showSSNScreen: (context, event) => Navigation.push('NameScreen'),
    }
  }
);

Our above state machine generates the following visualization:

xstate application machine

There a couple of new features to unpack here:

  1. Context API: The statechart maintains its own data store called its context which can be assigned to using the assign action from the library.
  2. Action handlers: Actions, as mentioned earlier, are synchronous side-effects that can be performed on transitioning, entering or exiting a state. Each time one of our UI states is entered, we perform a submit action to store the user’s information and push on a new screen.

Nested Flows

What if we wanted to allow a user to stop and resume applications for multiple financial products at a time?

As actors, each statechart service can spawn and manage its own child services. We can create a BankApplicationManagerMachine to handle multiple applications:

import { Machine, spawn, assign } from 'xstate';

const BankApplicationManagerMachine = Machine({
  context: {
    applications: {},
    activeApplication: null
  },
  states: {
    idle: {},
    opened: {},
  },
  on: {
    OPEN_APPLICATION: {
      target: 'opened',
      actions: assign((context, event) => {
        let application = context.applications[event.name];

        if (application) {
          return {
            ...context,
            activeApplication: application,
          };
        }

        application = spawn(BankApplicationMachine);

        return {
          applications: {
            ...context.applications,
            [event.name]: application
          },
          activeApplication: application,
        };
      })
    }
  }
});

Our UI can then start the manager service and let the user switch between applications:

import { useMachine, useService } from '@xstate/react'
import { useMemo, useCallback } from 'react';

const products = [
  { name: 'Chase Bank', id: 'chase' }, 
  { name: 'Bank of America', id: 'bank-of-america' }
];

const Application = ({ service }) => {
  const [current, send] = useService(service);

  if (current.matches('NAME')) {
    return (
      <NameForm
        initialData={current.context.name}
        onSubmit={(data) => send('SUBMIT', data)} 
      />
    );
  } else if (current.matches('DATE_OF_BIRTH')) {
    return (
      <DateOfBirthForm
        initialData={current.context.dateOfBirth}
        onSubmit={(data) => send('SUBMIT', data)}
      />
    );
  }
  ...
}

const ApplicationManager = () => {
  const [current, send] = useMachine(BankApplicationManagerMachine);
  const { activeApplication } = current.context;

  const handleOpenApplication = useCallback(
    product => send('OPEN_APPLICATION', { product }),
    [send]
  );

  const renderApplicationPicker = useMemo(() => products.map(product => (
    <a onClick={() => handleOpenApplication(product.name)}>{product}<a/>
  ), [handleOpenApplication]);

  return (
    <div>
      {renderApplicationPicker()}
      {activeApplication && <Application service={activeApplication} />}
    </div>
  );
};

Storing spawned applications and their data in a state machine separately from the UI has a number of advantages over a traditional data-in-components implementation:

  1. Applications can process data in the background even when their components are unmounted
  2. Application services and their data is stored by the application manager and can be persisted and restored without any external data mechanism.
  3. The UI framework layer (e.g., React) becomes a plain view layer; logic and side-effects are not tied directly to the UI, except where it is appropriate.
  4. Business logic is testable independent of UI

A New State of Mind

Whether you have written state machines before, there are always state machines in our code, either implicit or explicit.

Implicit ones are generally harder to maintain, visualize and get right. In theory, an explicit state machine can help developers optimize for correctness and clarity in their code. In practice, it is important to point out the potential downsides to relying on state machine implementations:

The investigation and examples we’ve looked at here make the case that the benefits of using state machines outweigh these concerns and ultimately present a compelling case for managing the shared, mutable states of our applications through explict state machines.

Investigating Further

There are excellent resources on both statecharts and xstate that I would recommend exploring if you’re interested in the topic: