State Machines With React

Managing complex React state with finite state machines

As web applications grow and user interactions become more complex, keeping track of state can become difficult. Libraries like Redux and MobX address broader state-management problems, but they also introduce their own concepts, conventions, and tooling.

State machines offer another way to model complex state and make valid transitions explicit. This article compares common approaches, introduces the basic theory behind state machines, and shows a practical React example.

Shortfalls of State Management in React

When it comes to state management in React applications, developers often start with built-in hooks like useState and useReducer, which are simple and straightforward ways to manage state at a component level. The useState hook is ok for handling simple states such as toggling a button or displaying a modal, as straightforward as:

const [isOpen, setIsOpen] = useState(false);

For slightly more complex state logic involving multiple actions, useReducer comes into play. It allows you to manage state transitions in a more structured way, inspired by the reducer function in Redux:

const [state, dispatch] = useReducer(reducer, initialState);

When state is shared among multiple components or needs to be persisted across routes, global state managers are usually introduced to a project, providing a single source of truth for the states and allowing you to manage it in a predictable way using actions and reducers.

Redux and MobX solve broader state-management problems in different ways. State machines are particularly useful when the main challenge is modeling which states and transitions are valid.

A primer on State Machines

In the realm of computer science, a state machine is a mathematical model of computation that provides an abstract representation of a system that can be in one of a finite number of states. It processes a sequence of events or inputs and transitions from one state to another based on a set of rules or conditions. Each transition can optionally produce an output or perform an action, but the primary focus is the controlled movement between states.

A basic state machine consists of:

  • States: The distinct conditions that the machine can exist in.
  • Transitions: Rules that dictate the switch from one state to another.
  • Initial State: The state where the machine starts.
  • Final State(s): States where the machine can terminate its operation (not always applicable).

The underlying mathematics of state machines is anchored in set theory. In deterministic finite-state machines, the transition function is often represented as δ : S × Σ → S. In this expression, S is a finite set of states, and Σ is the input alphabet, which represents possible actions and events that result in a change of state. The function δ maps each combination of a state in S and an input symbol in Σ to a single new state in S.

Math meme Simple enough, right?

Such formalism enables the application of rigorous analysis methods, like model checking, to ensure the intended behavior of the system is upheld. This simple concept appears in systems ranging from traffic lights to NPC behavior in video games.

Practical example: Multi-Step Form Component

Imagine you’re developing a travel booking application where users go through multiple steps to complete their reservation. This booking form example has these interconnected sections: Personal Information, Flight Selection, Payment Details, Review, and Confirmation.

Each section has unique data fields, validation logic, and potential side-effects like API calls. Additionally, the user experience becomes complex due to the ability to navigate through these sections both forwards and backwards, save progress, or even exit and return later.

To tackle this, you define specific states for each step, like personalInfo or flightSelection, and allow certain transitions between them. User actions like NEXT, PREV, and SUBMIT serve as triggers for these state transitions. Guards can prevent invalid transitions, while actions can handle side-effects such as API calls.

Create a Machine with XState

XState is a library that provides a comprehensive set of methods to define, interpret, and execute finite state machines and statecharts. It also has its own state visualizer called viz.

Let’s create a machine with an ID, an initial state, and possible states like personalInfo, flightSelection, and so on. Each state listens for specific events like NEXT or PREV, which when triggered, move the machine to a new target state. Conditions that determine whether a transition can occur are expressed as guards, while side-effects such as API calls can be included as actions.

import { createMachine } from 'xstate';

export const multiStepFormMachine = createMachine({
  id: 'multiStepForm',
  initial: 'personalInfo',
  context: {
    // Store additional data here if needed
  },
  states: {
    personalInfo: {
      on: {
        NEXT: {
          target: 'flightSelection',
          cond: () => {
            // Return whether the current form data is valid
            return true;
          }
        },
      },
    },
    flightSelection: {
      on: {
        NEXT: {
          target: 'paymentDetails',
          actions: [
            // Add side-effects such as fetching available flights here
          ]
        },
        PREV: 'personalInfo'
      },
    },
    paymentDetails: {
      on: {
        NEXT: 'review',
        PREV: 'flightSelection'
      },
    },
    review: {
      on: {
        NEXT: 'confirmed',
        PREV: 'paymentDetails',
        SUBMIT: 'confirmed'
      },
    },
    confirmed: {
      type: 'final'
    }
  }
});

Integrate with React

You can then import the previous file (named multiStepFormMachine.js in this example) and use this machine in your React component to manage its state:

import { useMachine } from '@xstate/react';
import { multiStepFormMachine } from './multiStepFormMachine';

function MultiStepForm() {
  const [state, send] = useMachine(multiStepFormMachine);

  return (
    <div>
      {state.matches('personalInfo') && <div>Personal Information Form</div>}
      {state.matches('flightSelection') && <div>Select Your Flight</div>}
      {state.matches('paymentDetails') && <div>Payment Details</div>}
      {state.matches('review') && <div>Review Your Information</div>}
      {state.matches('confirmed') && <div>Confirmation</div>}

      {!state.matches('personalInfo') && !state.matches('confirmed') && (
        <button onClick={() => send('PREV')}>Previous</button>
      )}
      {!state.matches('confirmed') && (
        <button onClick={() => send('NEXT')}>Next</button>
      )}
    </div>
  );
}

The hook useMachine from XState is used to manage the component’s state, returning the current state of the machine and a function to send events to the machine. The component uses conditionally rendered divs to display the form based on the current state checked via state.matches. The Previous button is omitted from the first state, and both navigation buttons are removed once the form reaches the final state.

Sharing machine state with React Context

XState’s useInterpret hook creates the machine service without subscribing the component to every state change. We can put that service in React Context so nested components can access it.

import React, { createContext } from 'react';
import { useInterpret } from '@xstate/react';
import { multiStepFormMachine } from './multiStepFormMachine';

export const FormStateContext = createContext({});

export const FormStateProvider = ({ children }) => {
  const formService = useInterpret(multiStepFormMachine);
  return (
    <FormStateContext.Provider value={{ formService }}>
      {children}
    </FormStateContext.Provider>
  );
};

Child components can access the state machine’s service through React’s useContext. The useActor hook subscribes to the service’s state changes, while useSelector lets a component subscribe to a selected value and re-render only when that value changes.

import React, { useContext } from 'react';
import { FormStateContext } from './FormStateContext';
import { useSelector } from '@xstate/react';

const flightSelector = (state) => {
  return state.matches('flightSelection');
};

export const FlightSummary = () => {
  const { formService } = useContext(FormStateContext);
  const isFlightSelected = useSelector(formService, flightSelector);

  return isFlightSelected ? <div>Summary of your flight choices</div> : null;
};

Further Reading

#React