DEV Community

Cover image for Finite State Machines: The Most Underused Design Pattern in Frontend Development
Osama Alghanmi
Osama Alghanmi

Posted on

Finite State Machines: The Most Underused Design Pattern in Frontend Development

If you're using useState for complex UI, you're probably doing it wrong. There's a 50-year-old solution you're ignoring.

The Boolean Flag Trap

Here's a familiar pattern:

function UserProfile() {
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);
  const [isEditing, setIsEditing] = useState(false);
  const [isSaving, setIsSaving] = useState(false);
  const [errorMessage, setErrorMessage] = useState('');

  const handleSave = async () => {
    setIsSaving(true);
    setIsError(false);
    try {
      await saveUser(user);
      setIsEditing(false);
    } catch (e) {
      setIsError(true);
      setErrorMessage(e.message);
    } finally {
      setIsSaving(false);
    }
  };

  // What combinations are valid?
  // isLoading=true, isError=true?
  // isEditing=true, isSaving=true?
  // Who knows!
}
Enter fullscreen mode Exit fullscreen mode

This creates 2^n possible states (32 combinations for 5 booleans). Most are invalid or nonsensical.

The State Machine Alternative

What if you explicitly defined valid states?

{
  "states": [
    { "name": "idle", "isInitial": true },
    { "name": "loading" },
    { "name": "editing" },
    { "name": "saving" },
    { "name": "error" }
  ],
  "events": ["FETCH", "EDIT", "SAVE", "SUCCESS", "ERROR", "CANCEL"],
  "transitions": [
    { "from": "idle", "to": "loading", "event": "FETCH" },
    { "from": "loading", "to": "idle", "event": "SUCCESS" },
    { "from": "loading", "to": "error", "event": "ERROR" },
    { "from": "idle", "to": "editing", "event": "EDIT" },
    { "from": "editing", "to": "saving", "event": "SAVE" },
    { "from": "saving", "to": "idle", "event": "SUCCESS" },
    { "from": "saving", "to": "error", "event": "ERROR" },
    { "from": "editing", "to": "idle", "event": "CANCEL" },
    { "from": "error", "to": "idle", "event": "CANCEL" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Now there are exactly 5 states and 9 valid transitions. No impossible combinations.

Visualizing the Difference

Boolean Flags: Spaghetti State

         isLoading=true
        /             \
isError=true?      isEditing=true?
      /                 \
     ?                   ?
Enter fullscreen mode Exit fullscreen mode

Any combination is possible. Bugs arise from invalid states you didn't consider.

State Machine: Directed Graph

                    ┌─────────┐
         ┌─────────►│  idle   │◄────────┐
         │          └────┬────┘         │
         │               │              │
    ERROR│          FETCH│         SUCCESS
         │               ▼              │
    ┌────┴───┐      ┌─────────┐        │
    │ error  │      │ loading │        │
    └───┬────┘      └────┬────┘        │
        ▲                │             │
        │           SUCCESS            │
        │                │             │
        │                ▼             │
        │           ┌─────────┐        │
        └───────────┤ editing ├────────┘
                    └────┬────┘
                         │ SAVE
                         ▼
                    ┌─────────┐
         ┌─────────│ saving  │─────────┐
         │         └─────────┘         │
    ERROR│                              │SUCCESS
         │                              │
         └──────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Every path is explicit. Invalid transitions don't exist.

Real-World Example: Form Submission

The Boolean Way

function ContactForm() {
  const [formData, setFormData] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [isSuccess, setIsSuccess] = useState(false);
  const [isError, setIsError] = useState(false);
  const [errorMessage, setErrorMessage] = useState('');

  const submit = async () => {
    setIsSubmitting(true);
    setIsError(false);
    setIsSuccess(false);

    try {
      await api.submit(formData);
      setIsSuccess(true);
    } catch (e) {
      setIsError(true);
      setErrorMessage(e.message);
    } finally {
      setIsSubmitting(false);
    }
  };

  // Bug: What if isSuccess and isError are both true?
  // Bug: Can I submit again while isSubmitting?
  // Bug: What clears isSuccess?
}
Enter fullscreen mode Exit fullscreen mode

The State Machine Way

Define explicit states and transitions:

{
  "states": [
    { "name": "editing", "isInitial": true },
    { "name": "validating" },
    { "name": "submitting" },
    { "name": "success", "isTerminal": true },
    { "name": "error" }
  ],
  "events": ["SUBMIT", "VALIDATED", "SUCCESS", "FAILURE", "RETRY", "EDIT"],
  "transitions": [
    { "from": "editing", "to": "validating", "event": "SUBMIT" },
    {
      "from": "validating",
      "to": "submitting",
      "event": "VALIDATED",
      "guard": ["=", "@validation.valid", true]
    },
    {
      "from": "validating",
      "to": "editing",
      "event": "VALIDATED",
      "guard": ["=", "@validation.valid", false]
    },
    { "from": "submitting", "to": "success", "event": "SUCCESS" },
    { "from": "submitting", "to": "error", "event": "FAILURE" },
    { "from": "error", "to": "editing", "event": "RETRY" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • ✅ Can't submit while already submitting
  • ✅ Validation happens in its own state
  • ✅ Error and success are mutually exclusive
  • ✅ Clear paths for retry

If you're using XState, this maps directly. If you prefer a lighter approach, even a plain TypeScript discriminated union gets you most of the way:

type FormState =
  | { status: 'editing' }
  | { status: 'validating' }
  | { status: 'submitting' }
  | { status: 'success' }
  | { status: 'error'; message: string };
Enter fullscreen mode Exit fullscreen mode

Why Developers Avoid State Machines

Myth 1: "They're Too Complex"

Reality: Boolean flags seem simpler until you have 5+ of them. Then the interaction matrix becomes incomprehensible.

Myth 2: "They're Only for Games"

Reality: Game developers use FSMs because they work. UI is just like a game: user actions trigger state changes.

Myth 3: "They're Hard to Change"

Reality: Changing a state machine means adding a state or transition. Changing boolean flags means hunting through useEffect chains.

When to Use State Machines

Scenario Boolean Flags State Machine
2-3 simple states ✅ Okay ✅ Better
Async operations ❌ Buggy ✅ Clear
Multi-step flows ❌ Messy ✅ Perfect
Complex UI modes ❌ Impossible ✅ Ideal

Real-World Analogy: Traffic Lights

Traffic lights are the canonical state machine:

Red → Green → Yellow → Red
Enter fullscreen mode Exit fullscreen mode

Imagine if traffic lights used boolean flags:

const [isRed, setIsRed] = useState(true);
const [isGreen, setIsGreen] = useState(false);
const [isYellow, setIsYellow] = useState(false);

// Bug: All could be true!
// Bug: All could be false!
// Bug: Green could turn directly to Red!
Enter fullscreen mode Exit fullscreen mode

Traffic engineers use state machines because lives depend on predictable states.

Your users' sanity depends on it too.

Try It: Convert a Boolean Mess

Take this boolean-heavy component:

function Checkout() {
  const [isCartOpen, setIsCartOpen] = useState(false);
  const [isCheckingOut, setIsCheckingOut] = useState(false);
  const [isProcessing, setIsProcessing] = useState(false);
  const [isComplete, setIsComplete] = useState(false);
  const [hasError, setHasError] = useState(false);
  // ... nightmare of useEffect
}
Enter fullscreen mode Exit fullscreen mode

Convert to explicit states:

states: browsing → cartOpen → checkoutForm → processing → complete
                                                        ↘ error → checkoutForm (retry)
Enter fullscreen mode Exit fullscreen mode

The state machine version has 6 explicit states instead of 32 possible boolean combinations.

The Takeaway

Finite state machines aren't academic exercises — they're practical tools for managing complexity.

  • 2-3 booleans: Probably fine
  • 4+ booleans: Consider a state machine
  • Async flows: Definitely use a state machine
  • Multi-step UI: State machine or bust

Libraries like XState make this easy in any React/Vue/Svelte project.

Top comments (0)