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.

3 min read

Introduction

The state machine pattern models UI with a single status value and explicit transitions between allowable states. It replaces multiple booleans with one clear source of truth.

Why this matters

As components grow, it’s easy to end up with multiple booleans like loading, error, and ready. That invites impossible states (e.g., loading and error at the same time) and leads to tangled conditionals.

A simple state machine replaces those with a single status value. Your UI becomes predictable because only one state can be active at a time, and transitions are explicit.

The problem

A common approach is to juggle multiple booleans:

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

Transitions juggle multiple setters and the render logic checks three flags—easy to break and hard to extend. This can even lead to impossible combinations like loading and error both being true.

Inefficient approach

Multiple booleans create confusion and allow impossible states:

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;

The solution

Use a single status string that can be "ready" | "loading" | "error". Render based on status and switch states 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>
  );
}

Step-by-step

  1. Replace multiple booleans with const [status, setStatus] = useState("ready").
  2. Update transitions in a single handleNextState function.
  3. Render conditionally from status only—no mixed flags.
  4. Optionally encode transitions in a map for clarity and testability.
  5. Add new states by adding new explicit string values and render branches.

Why this is better

  • Only valid states exist; you can’t accidentally be both loading and error.
  • Render logic is straightforward and maintainable.
  • Adding new states (like "success") is just another explicit value.

Extensions and best practices

  • Centralize transition rules. For non‑trivial flows, encode allowed transitions in a map:
const transitions = {
  ready: "loading",
  loading: "error",
  error: "ready",
};

setStatus(prev => transitions[prev]);
  • Use enums or union types for type safety in TypeScript.
  • Side effects should react to state changes, not trigger them implicitly. Keep transitions pure; handle effects in useEffect.

Takeaway

Modeling UI as a state machine makes transitions explicit and eliminates impossible states. Start with a simple status value—add structure only as the flow demands it.

Knowledge base

Problem snippet:

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

Solution snippet:

const [status, setStatus] = useState("ready");
if (status === "ready") setStatus("loading");
// render: status === "loading" | "error" | "ready"