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

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

Osama Alghanmi · February 19, 2026

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:

typescript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 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! }

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?

json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 { "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" } ] }

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? / \ ? ?

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 │ │ └──────────────────────────────┘

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

Real-World Example: Form Submission

The Boolean Way

typescript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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? }

The State Machine Way

Define explicit states and transitions:

json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 { "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" } ] }

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:

typescript
1 2 3 4 5 6 type FormState = | { status: 'editing' } | { status: 'validating' } | { status: 'submitting' } | { status: 'success' } | { status: 'error'; message: string };

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

ScenarioBoolean FlagsState 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

Imagine if traffic lights used boolean flags:

javascript
1 2 3 4 5 6 7 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!

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:

typescript
1 2 3 4 5 6 7 8 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 }

Convert to explicit states:

states: browsing → cartOpen → checkoutForm → processing → complete ↘ error → checkoutForm (retry)

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.

Osama Alghanmi

Knowledge is freedom. Systems should be open to the people they govern.

© 2026 Osama Alghanmi. Ljubljana, Slovenia.