Taming Application State in JavaScript with Statecharts

As a full-stack JavaScript developer, I‘ve lost count of how many times I‘ve struggled to make sense of the tangled web of state logic in a complex front-end application. Pop-up modals with multiple toggle flags, components whose rendering logic is determined by a handful of booleans, so-called "easy" state management libraries that lead to convoluted update flows and impossible-to-reproduce bugs.

If this sounds familiar, you‘re not alone. A recent survey of over 20,000 JavaScript developers found that state management is the number one pain point in front-end development, with 67% of respondents expressing dissatisfaction with their current solution.

There has to be a better way. And there is. It‘s a concept that‘s been around for decades in the world of embedded systems and control theory, but is only now starting to gain traction in the JavaScript community. It‘s called a statechart, and it‘s going to change the way you think about designing and managing application state.

The Problem with Informal State Management

To understand why statecharts are so powerful, let‘s first examine the problems with how most JavaScript applications handle state today.

Typically, the state of a front-end application is scattered across various component instances, reducers, and local variables. The logic for updating state is often intermixed with the UI rendering code, making it difficult to reason about.

Even with libraries like Redux that aim to consolidate state updates into a single reducer function, the actual transitions between states are implicit and hard to follow. The reducer pattern encourages a mindset of "do X to the state when Y happens", rather than modeling the high-level states the application can be in and the events that trigger transitions between them.

The lack of an explicit state model makes it all too easy for edge cases and inconsistencies to slip through the cracks. A study by software consultancy Thoughtworks found that over 75% of software bugs are caused by unanticipated or incorrectly handled application states.

The problem compounds as the application grows in features and complexity. Pretty soon no single developer on the team holds the entire state flow of the application in their head. Fixes in one area inadvertently break seemingly unrelated features. Implementing new behaviors requires tiptoeing through a minefield of flags and conditions.

As professional JavaScript developers, we need a more principled and scalable approach to state management. And that‘s where statecharts come in.

Statecharts: A Better Way to Model Application State

Statecharts were first introduced by computer scientist David Harel in his seminal 1987 paper "Statecharts: A Visual Formalism for Complex Systems". Harel was working on designing complex reactive systems and found that traditional finite state machines quickly became unmanageable as the number of states and transitions grew.

His insight was to introduce hierarchy and concurrency to state machines. With statecharts, states can be nested inside other states, allowing for more abstract and modular representations of system behavior. Additionally, orthogonal (parallel) states can be active at the same time, enabling modeling of independent sub-systems.

These innovations sound simple, but they enable expressing even the most complex application logic in a clear and concise manner. Statecharts provide a high-level visual language for designing and communicating the behavior of your application.

To see why statecharts are so powerful, let‘s model a common UI component – a payment form with validation and submission. Here‘s how it might look as a statechart:

stateDiagram-v2
  [*] --> Editing
  Editing --> Validating : submit
  Validating --> Editing : invalid
  Validating --> Submitting : valid
  Submitting --> Success : success
  Submitting --> Error : error
  Success --> [*]
  Error --> [*]

Even at a glance, this diagram provides a clear overview of the possible states the form can be in and how it should transition between them. We can see that:

  • The form starts in the "Editing" state
  • Submitting the form triggers a transition to the "Validating" state
  • If validation fails, we transition back to "Editing"
  • If validation succeeds, we transition to the "Submitting" state
  • A successful submission ends in the "Success" state, while an error ends in the "Error" state

This declarative, visual approach to defining application logic has several key benefits:

  1. Shared understanding: The whole team, including non-technical stakeholders, can collaborate on designing the behavior of the system using a common visual language.

  2. Explicitness: Every possible state of the application is represented explicitly, making it easier to handle edge cases and ensure consistent behavior.

  3. Modularity: States can be composed hierarchically, allowing for building complex behaviors from smaller, reusable pieces.

  4. Testability: With a clear specification of all possible states and transitions, it‘s much easier to automatically generate test cases that cover every scenario.

Statecharts provide a powerful conceptual model for reasoning about application state, but how do we actually implement them in our JavaScript code?

Implementing Statecharts in JavaScript with XState

While you could roll your own implementation of a statechart interpreter, a number of well-maintained libraries already exist, with XState being the most popular and feature-rich.

To demonstrate the basics of working with XState, let‘s implement the payment form example from above:

import { createMachine, assign } from ‘xstate‘;

const formMachine = createMachine({
  id: ‘form‘,
  initial: ‘editing‘,
  context: {
    formData: {},
    error: null
  },
  states: {
    editing: {
      on: {
        SUBMIT: ‘validating‘  
      }
    },
    validating: {
      always: [
        { target: ‘submitting‘, cond: ‘isValid‘ },
        { target: ‘editing‘ }
      ]
    },
    submitting: {
      invoke: {
        src: ‘submitForm‘,
        onDone: { target: ‘success‘, actions: ‘setResult‘ },
        onError: { target: ‘error‘, actions: ‘setError‘ }
      }
    },
    success: { type: ‘final‘ },
    error: { type: ‘final‘ }  
  }
}, {
  guards: {
    isValid: (context) => {
      // Perform validation logic here
      return true;
    }
  },
  services: {  
    submitForm: (context) => {
      // Make API call to submit form data
      return Promise.resolve(context.formData);
    }
  },
  actions: {
    setResult: assign((context, event) => {
      return { result: event.data };
    }),
    setError: assign((context, event) => {
      return { error: event.data };  
    })
  }
});

There‘s a lot to unpack here, but the key points are:

  • We define the shape of our state machine using a plain JavaScript object passed to createMachine. This includes defining the initial state, possible states, and the events that trigger transitions between them.

  • The context property holds any relevant data that the machine needs to keep track of, such as the current form values and any error messages. This data can be updated using "actions".

  • "Guards" are functions that determine whether a transition should occur. Here we use a guard to check if the form data is valid before transitioning to the "submitting" state.

  • "Services" are used to model asynchronous processes like submitting the form data to a server. The onDone and onError properties define transitions to take depending on the result of the asynchronous operation.

  • The assign action creator is used to update the machine‘s context in response to events and service results.

With our state machine defined, we can now interpret it and interact with it from our application code:

import { interpret } from ‘xstate‘;

const formService = interpret(formMachine)
  .onTransition(state => console.log(state.value))
  .start();

// Send events to the service
formService.send(‘SUBMIT‘);

The interpret function takes our machine definition and returns a service that we can subscribe to and send events. Every time a state transition occurs, our onTransition callback will be invoked with the next state.

This is just a small taste of what‘s possible with XState. The library provides a number of other powerful features, including:

  • Hierarchical and parallel states for building complex state machines from smaller pieces
  • History states for preserving and restoring previous states
  • Activities for triggering side effects when entering or exiting a state
  • Delayed transitions and events
  • And much more!

By modeling your application logic as a statechart and implementing it with XState, you can greatly simplify your state management code and make it more robust and maintainable.

Integrating Statecharts with Modern Front-End Frameworks

One of the great things about statecharts is that they are largely agnostic of the specific view layer you‘re using. Whether you‘re working with React, Vue, Angular, or vanilla JavaScript, you can model your application state with a statechart and reap the benefits.

That said, using statecharts with a declarative component-based framework like React is a particularly potent combination. By cleanly separating the application state and logic into a statechart, your view components can become simpler and more focused on rendering the current state.

Here‘s how you might connect the payment form state machine to a React component:

import React from ‘react‘;
import { useMachine } from ‘@xstate/react‘;
import { formMachine } from ‘./formMachine‘;

function PaymentForm() {
  const [state, send] = useMachine(formMachine);

  switch (state.value) {
    case ‘editing‘:
      return (
        <form onSubmit={() => send(‘SUBMIT‘)}>
          {/* Render form inputs */}
          <button type="submit">Submit</button>
        </form>
      );
    case ‘validating‘:
      return <div>Validating...</div>;
    case ‘submitting‘:
      return <div>Submitting...</div>;
    case ‘success‘:
      return <div>Payment Successful!</div>;
    case ‘error‘:
      return <div>Error: {state.context.error.message}</div>;
    default:
      return null;
  }
}

By using the useMachine hook from the @xstate/react package, we can easily access the current state and a send function to dispatch events. The component then simply switches on the state value to determine what to render.

This approach keeps the rendering logic clean and declarative. Instead of a tangled mess of conditional statements, each possible state of the form is described separately. Adding additional states or transitions is as simple as updating the state machine definition.

The Future of Visual State Management

As JavaScript applications continue to grow in size and complexity, the need for more robust and scalable state management solutions will only become more pressing. Statecharts offer a compelling vision for the future of application development, one where the behavior of our systems can be visualized and reasoned about at a high level.

But the statechart paradigm is still in its early stages in the JavaScript world. While libraries like XState are a huge step forward, there‘s still much work to be done to make statecharts a mainstream tool in the front-end developer‘s arsenal.

Some promising areas for exploration and improvement:

  • More robust tooling for visually designing and simulating statecharts. Tools like Sketch.systems and ivy.js are a great start, but we need more mature and feature-rich solutions.

  • Tighter integration with front-end frameworks. Automatically deriving a component tree from a statechart, or syncing statechart state with a framework like Redux, could make adoption much easier.

  • Improved testing and debugging support. Automatically generating test cases from a statechart definition is a powerful concept, but the tooling is still fairly immature. Similarly, visualizing the live state of a running application in development could greatly aid debugging.

As more developers discover the power of statecharts, I‘m excited to see how the ecosystem evolves and matures. With a principled and visual approach to state management, we can make our applications more robust, maintainable, and even (gasp) fun to work on!

Learn More

If you‘re intrigued by the potential of statecharts for state management, here are some resources to dive deeper:

Similar Posts