Back to all notes

State Machine Pattern: Replace Boolean Soup with a Single Status

Model UI states explicitly to prevent impossible combinations and clarify transitions.

2 min read

As a component grows, it’s easy to wake up owning three booleans:

const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [ready, setReady] = useState(true);

Nothing stops loading and error from being true at the same time. The render logic checks three flags, transitions juggle multiple setters, and every new state multiplies the combinations. The state machine pattern replaces all of it with a single status value and explicit transitions. One state active at a time, by construction.

Boolean Soup

The full version of the problem:

import { useState } from "react";

function App() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(false);
  const [ready, setReady] = useState(true);

  const handleNextState = () => {
    if (ready) {
      setReady(false);
      setLoading(true);
    } else if (loading) {
      setLoading(false);
      setError(true);
    } else if (error) {
      setError(false);
      setReady(true);
    }
  };

  return (
    <div>
      <h1>State Machine Pattern</h1>
      <button onClick={handleNextState}>Next State</button>
      {loading && <p>Loading...</p>}
      {error && <p>Error!</p>}
      {ready && <p>Ready</p>}
    </div>
  );
}

export default App;

One Status to Rule the Render

Use a single status string, "ready" | "loading" | "error". Render from status, transition with one setter:

import { useState } from "react";

function App() {
  const [status, setStatus] = useState("ready");

  const handleNextState = () => {
    if (status === "ready") {
      setStatus("loading");
    } else if (status === "loading") {
      setStatus("error");
    } else if (status === "error") {
      setStatus("ready");
    }
  };

  return (
    <div>
      <h1>State Machine Pattern</h1>
      <button onClick={handleNextState}>Next State</button>

      {status === "loading" && <p>Loading...</p>}
      {status === "error" && <p>Something went wrong!</p>}
      {status === "ready" && <p>Ready to go!</p>}
    </div>
  );
}

Impossible states are gone because only valid states exist. The render branches on one value instead of three, and adding a state like "success" is just another explicit value and branch.

When Transitions Get Serious

For non-trivial flows, encode the allowed transitions in a map instead of an if-chain:

const transitions = {
  ready: "loading",
  loading: "error",
  error: "ready",
};

setStatus(prev => transitions[prev]);

In TypeScript, a union type or enum for status catches typos at compile time. One rule I keep: transitions stay pure, and side effects react to state changes in useEffect rather than firing from inside the transition itself. Start with the plain status value and add the map only when the flow demands it.