State Machine Pattern: Replace Boolean Soup with a Single Status
Model UI states explicitly to prevent impossible combinations and clarify transitions.
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
- Replace multiple booleans with
const [status, setStatus] = useState("ready"). - Update transitions in a single
handleNextStatefunction. - Render conditionally from
statusonly—no mixed flags. - Optionally encode transitions in a map for clarity and testability.
- 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"