Skip to main content

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

· 5 min read
Osamah Alghanmi
Co-Founder & Technical Lead

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

Entity(Matter)Page(Space)Trait(Energy)idleactivehas_traitrenderstransition
Orbital Unit = Entity + Traits + Pages

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!
}

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" }
]
}

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

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

{
"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",
"effects": [["validate", "@entity"]]
},
{
"from": "validating",
"to": "submitting",
"event": "VALIDATED",
"guard": ["=", "@validation.valid", true],
"effects": [["call-service", "submitForm", "@entity"]]
},
{
"from": "validating",
"to": "editing",
"event": "VALIDATED",
"guard": ["=", "@validation.valid", false],
"effects": [["set", "@state.errors", "@validation.errors"]]
},
{
"from": "submitting",
"to": "success",
"event": "SUCCESS",
"effects": [["render-ui", "main", { "type": "success-state" }]]
},
{
"from": "submitting",
"to": "error",
"event": "FAILURE",
"effects": [["set", "@state.error", "@payload.message"]]
},
{
"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

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

Almadar Makes It Easy

In Almadar, you don't implement the state machine — you declare it:

{
"traits": [{
"name": "TaskManager",
"linkedEntity": "Task",
"stateMachine": {
"states": [
{ "name": "browsing", "isInitial": true },
{ "name": "creating" },
{ "name": "editing" },
{ "name": "deleting" }
],
"events": ["INIT", "CREATE", "EDIT", "DELETE", "SAVE", "CANCEL"],
"transitions": [
{
"from": "browsing",
"to": "browsing",
"event": "INIT",
"effects": [
["render-ui", "main", { "type": "entity-table", "entity": "Task" }]
]
},
{
"from": "browsing",
"to": "creating",
"event": "CREATE",
"effects": [
["render-ui", "modal", { "type": "form-section", ... }]
]
},
{
"from": "creating",
"to": "browsing",
"event": "SAVE",
"effects": [
["persist", "create", "Task", "@payload.data"],
["render-ui", "modal", null],
["emit", "INIT"]
]
},
{
"from": "creating",
"to": "browsing",
"event": "CANCEL",
"effects": [["render-ui", "modal", null]]
}
]
}
}]
}

The compiler generates:

  • State machine runtime
  • TypeScript types
  • Event handlers
  • UI bindings

You just define the logic.

Real-World Analogy: Traffic Lights (Again)

Traffic lights are the canonical state machine:

Red → Green → Yellow → Red

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!

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
}

And convert to Almadar schema:

{
"states": [
{ "name": "browsing", "isInitial": true },
{ "name": "cartOpen" },
{ "name": "checkoutForm" },
{ "name": "processing" },
{ "name": "complete", "isTerminal": true },
{ "name": "error" }
],
"events": ["VIEW_CART", "CHECKOUT", "SUBMIT", "SUCCESS", "FAILURE", "CLOSE", "RETRY"]
// ... transitions
}

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

Almadar makes state machines the default, not the exception. Because your users deserve predictable software.

Ready to try? Build your first state machine.

Recent Posts