MAISON CODE .
/ Architecture · XState · React · Testing · Engineering Patterns

State Machines: Making Impossible States Impossible

Why 'isLoading' booleans breed bugs. A deep dive into Finite State Machines (FSM), Statecharts, and XState for bulletproof UI logic.

AB
Alex B.
State Machines: Making Impossible States Impossible

Let’s look at a typical component written by a Junior Developer.

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

const fetchData = async () => {
  setIsLoading(true);
  try {
    const res = await api.get();
    setData(res);
  } catch (err) {
    setIsError(true);
  }
  setIsLoading(false); // Bug: What if error occurred? logic split.
};

The Bug: If catch block runs, isError becomes true. Then setIsLoading(false) runs. But what if the user clicks “Retry”? Use start fetching again. isLoading is true. isError is also true (from previous run). The UI shows a Spinner AND an Error Message simultaneously. This is an Impossible State.

You fix it by adding setIsError(false) at the start. Then you add isSuccess. Now you have 3 booleans. $2^3 = 8$ possible combinations. Only 4 are valid. 50% of your state space is bugs.

At Maison Code Paris, we optimize for reliability. We reject “Boolean Soup”. We use State Machines.

Why Maison Code Discusses This

We build financial dashboards where a “glitch” isn’t just annoying; it’s a liability. We use State Machines (XState) to guarantee correctness:

  • Safety: Impossible states (Loading + Error) are mathematically unrepresentable.
  • Documentation: The code is the diagram. We export statecharts to show stakeholders exactly how the checkout flow works.
  • Testability: We auto-generate 100% path coverage tests from the machine definition. We don’t hope it works; we prove it works.

The Finite State Machine (FSM)

A State Machine is a model of behavior. It consists of:

  1. States: (e.g., idle, loading, success, failure).
  2. Events: (e.g., FETCH, RETRY, CANCEL).
  3. Transitions: (e.g., idle + FETCH -> loading).

Crucially, the machine can only be in one state at a time. If you are effectively in the loading state, and the FETCH event happens (user double clicks), the machine ignores it (unless you explicitly allow it). Race conditions vanish.

XState: The Library

We use XState. It is the standard for FSMs in JavaScript. It implements Statecharts (W3C SCXML standard), which allows for:

  • Hierarchical States (Parent/Child).
  • Parallel States (Orthogonal regions).
  • History States (Remembering where you left off).

Implementation

import { createMachine, assign } from 'xstate';

const fetchMachine = createMachine({
  id: 'fetch',
  initial: 'idle',
  context: {
    data: null,
    error: null,
  },
  states: {
    idle: {
      on: { FETCH: 'loading' }
    },
    loading: {
      // Invoke a Promise (Service)
      invoke: {
        src: 'fetchData',
        onDone: {
          target: 'success',
          actions: assign({ data: (context, event) => event.data })
        },
        onError: {
          target: 'failure',
          actions: assign({ error: (context, event) => event.data })
        }
      }
    },
    success: {
      // Terminal state? Or maybe allow refresh
      on: { REFRESH: 'loading' }
    },
    failure: {
      on: { RETRY: 'loading' }
    }
  }
});

Notice the clarity. Can you RETRY when success? No. The transition is not defined. Can you FETCH when loading? No. The logic is strict by design.

Guards and Context

Sometimes, transitions are conditional. “User can move to payment state ONLY IF formIsValid is true.”

// Guard
on: {
  NEXT: {
    target: 'payment',
    cond: (context) => context.formIsValid
  }
}

This effectively moves “Business Logic” out of the View Layer (React Components) and into the Model Layer (Machine). The React Component becomes dumb. It just renders state and sends events. machine.send('NEXT'). It doesn’t care if it goes next. The machine decides.

Parallel States (Orthogonal Regions)

Real apps are complex. Imagine an Upload Widget.

  1. It is uploading a file (0% -> 100%).
  2. The User can Minimize/Maximize the widget.

These are independent. You can be uploading AND minimized. XState handles this via Parallel States.

states: {
  uploadProcess: {
    initial: 'pending',
    states: { pending: {}, uploading: {}, complete: {} }
  },
  ui: {
    initial: 'expanded',
    states: { expanded: {}, minimized: {} }
  }
}

Visualizer and Communication

The best feature of XState is the Visualizer. You can copy-paste your code into stately.ai/viz and it generates an interactive diagram. We use this to communicate with Product Managers. PM: “The user should not be able to cancel once payment starts.” Dev: “Look at the diagram. There is no CANCEL arrow from the processing_payment state.” It aligns the Mental Model with the Code Model.

Model-Based Testing

Since current implementation is a graph, we can generate tests automatically. @xstate/test can calculate the Shortest Path to every state. It will generate a test plan:

  1. Start at idle.
  2. Fire FETCH.
  3. Expect loading.
  4. Resolve promise.
  5. Expect success.

It ensures you have 100% coverage of your logic flows.

10. The Actor Model (XState Actors)

A single Machine is great. But what if you have 10 Upload Widgets? You don’t want one giant uploadMachine. You want to spawn 10 little uploadActors. The Parent Machine (The Page) communicates with the Child Actor (The Widget) via messages (send({ type: 'UPLOAD_COMPLETE' })). This is the Actor Model (popularized by Erlang/Elixir). It provides isolation. If one actor crashes, it doesn’t crash the whole app. XState makes this trivial using spawn().

11. Visual State Designers

Why write code at all? Stately.ai allows you to drag-and-drop boxes to design the logic. Because the code is the diagram (isomorphic), the designer exports JSON that your code imports. This opens the door for Low-Code Logic maintained by Senior Engineers. It ensures that the “Business Rules” are visible to stakeholders, not hidden in useEffect spaghetti.

13. Hierarchical States (Compound States)

A Checkout is not just one list of states. It has phases.

  • Checkout:
    • Shipping: (address, method)
    • Payment: (card_entry, 3ds_verification) If you cancel Payment, do you go back to Shipping? With Hierarchical states, you can transition to checkout.shipping.history. This allows us to model complex flows without “State Explosion”. We group related states into a parent node. The parent handles global events (e.g., LOGOUT transitions to home from any sub-state).

14. Testing: The Model IS the Test

With @xstate/test, we don’t write manual E2E tests for every button click. We write Path Assertions.

  • In shipping state, assert getByText("Shipping Address").
  • In payment state, assert getByText("Credit Card"). The library then runs a Directed Graph traversal (Shortest Path) and executes Puppeteer/Playwright. It generates hundreds of tests automatically. If you add a new state, the tests update themselves.

15. Handling State Explosion

The biggest criticism of FSMs is “State Explosion”. If you have 10 booleans, you have $2^{10} = 1024$ states. Listing them all in a machine is impossible. Solution: Parallel States (Orthogonality). Instead of loading_and_modal_open, loading_and_modal_closed, success_and_modal_open… You have two regions: data: { loading, success } AND ui: { modal_open, modal_closed }. This reduces the complexity from Multiplicative ($M * N$) to Additive ($M + N$).

16. Formal Verification (Safety)

Because XState is a mathematical graph, we can mathematically prove things. “Is it possible to reach the payment state without passing through shipping?” We can run a graph algorithm to check if a path exists. If it does, the build fails. This is Formal Verification. It is usually reserved for NASA/Avionics, but XState brings it to React forms. This gives us confidence that our “Gatekeeping” logic is unbreakable.

17. Conclusion

State Machines add verbosity. Writing a machine takes longer than useState. But for complex flows (Checkout, Onboarding, Wizards), the ROI is massive. You trade “Implementation Speed” for “Maintenance Speed” and “Reliability.”

We believe that UI Logic is Algorithm Design. It deserves formal modeling.


Buggy Checkout?

Do you have race conditions in your payment flow?

Hire our Architects.