State Machines and XState

The Problem with Boolean Soup

In modern UI development, we often find ourselves managing component state with a “soup” of booleans:

const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState(null);

This leads to Impossible States, such as isLoading being true at the same time as isError. These bugs are difficult to test and even harder to debug.

The Solution: Finite State Machines (FSM)

A State Machine is a model of computation that says a system can be in exactly one of a finite number of states at any given time. Transitions between states happen in response to events.


Why Use XState?

XState is the industry-standard library for creating and managing state machines and statecharts in JavaScript.

Core Benefits:

  1. Predictability: You explicitly define every possible state and every valid transition.
  2. Visualization: XState provides tools to automatically generate a visual diagram of your logic.
  3. Encapsulation: Your business logic lives outside the component, making it reusable and easier to test.
  4. Hierarchical & Parallel States: Handles complex scenarios like a “nested” wizard or a UI that has multiple independent sections active at once.

Technical Implementation

1. Defining the Machine

We define our states (idle, loading, success, error) and the events that trigger transitions between them.

import { createMachine } from "xstate";

export const requestMachine = createMachine({
  id: "request",
  initial: "idle",
  states: {
    idle: {
      on: { SUBMIT: "loading" },
    },
    loading: {
      on: {
        SUCCESS: "success",
        FAILURE: "error",
      },
    },
    success: {
      on: { RETRY: "loading" },
    },
    error: {
      on: { RETRY: "loading" },
    },
  },
});

Practice Exercise

Integrate the machine above into a React component. Ensure the “Submit” button is only visible when the state is idle or error.


Answer

React Component with @xstate/react

import React from "react";
import { useMachine } from "@xstate/react";
import { requestMachine } from "./requestMachine";

export const SafeForm = () => {
  const [state, send] = useMachine(requestMachine);

  const handleSubmit = async () => {
    send("SUBMIT"); // Transition to 'loading'
    try {
      await fakeApiCall();
      send("SUCCESS");
    } catch {
      send("FAILURE");
    }
  };

  return (
    <div className="p-4 border">
      {/* Declarative state matching */}
      {state.matches("loading") && <Spinner />}

      {state.matches("success") && (
        <Alert type="success">Operation Completed!</Alert>
      )}

      {/* Button only visible in specific states */}
      {(state.matches("idle") || state.matches("error")) && (
        <button onClick={handleSubmit}>
          {state.matches("error") ? "Try Again" : "Submit"}
        </button>
      )}
    </div>
  );
};

Why This Architecture Works

  1. Guaranteed Correctness: It is mathematically impossible for the UI to be in success and loading simultaneously.
  2. Logic Separation: The requestMachine can be tested in isolation (Node.js) without needing to render a single React component.
  3. Communication: You can show the automatically generated XState diagram to a product manager or designer to confirm the business logic before writing a line of CSS.

Summary

State machines turn “implicit” logic (spread across many useEffect and useState calls) into “explicit,” documented behavior. While they have a steeper learning curve, they are the secret weapon for building robust, bulletproof UIs that never break on edge cases.