State Machine Thinking for UI
Model every interactive component as a finite set of states and transitions to eliminate impossible UI scenarios and design resilient, predictable interfaces.
9 min read
The full lesson
Every confusing UI bug has the same root cause: nobody defined all the states a component could be in. A button that looks clickable but does nothing. A form that submits twice. A spinner that never goes away. Each of these happens when the designer or developer skipped the hard question: what are all the possible states, and what should happen in each one?
State machine thinking means answering that question explicitly — before you draw anything or write any code.
This isn’t just a programming concept. It maps directly onto how real interfaces behave. Adopt it, and you’ll catch edge cases your teammates miss every time.
What a State Machine Actually Is
A finite state machine (FSM) has three parts:
- A fixed set of states — every condition the system can be in, all mutually exclusive.
- A set of events — things that can happen: a user click, a data response, a timer, an external signal.
- Transitions — rules that say “when you’re in state X and event Y happens, move to state Z (and optionally run action A).”
The word “finite” is the key constraint. A component can only be in one state at a time. It can never be in an undefined state. When you list all the states up front, the gray areas vanish.
Consider a simple submit button. Without state modeling, it’s just “a button.” With FSM thinking, it becomes:
| State | Visual | User can interact? |
|---|---|---|
idle | Enabled, default label | Yes — can click |
loading | Spinner, disabled | No — click blocked |
success | Checkmark, brief feedback | No — auto-transitions out |
error | Error label, re-enabled | Yes — retry allowed |
disabled | Grayed out, no cursor | No |
That table is a specification. It removes ambiguity for everyone: designers know what to design, engineers know what to implement, and QA knows exactly what to test.
The Seven Universal UI States
Most interactive components need some version of these seven states. For each one, ask: “does my component need this?” That question alone will stop you from shipping half-finished components.
1. Empty / Initial The component exists but has no content yet. A feed with no posts, a dashboard with no data, a search field before the first query. Empty states are a critical first impression. They should guide users toward the next action — not show a blank void.
2. Loading Data is being fetched, or an operation is in progress. Use skeleton screens for content-heavy layouts like feeds, card grids, and article bodies. Reserve spinners only for short blocking operations under about two seconds. Using a generic spinner for everything is outdated. It gives users no sense of what is loading or how long it will take.
3. Partial / Populated Some data is there, but not all of it. A table with three rows when the user expects hundreds, or a form partially pre-filled. This state is routinely forgotten — designs jump from empty to fully populated, skipping the awkward in-between.
4. Error Something went wrong. Good error states are specific (what failed), actionable (what to do next), and recoverable (a clear path back). “Something went wrong” is not an error message. It is a failure of design.
5. Success An operation completed. This state should be visible but brief — enough to confirm the action, not so persistent it becomes noise. A success state that never clears trains users to ignore confirmations.
6. Disabled The component exists but can’t be used right now. Always communicate why — through a nearby label or tooltip. A disabled button with no explanation is a usability trap. Under WCAG 2.2, disabled elements are exempt from contrast requirements, but good UX still demands an explanation.
7. Focused The component has keyboard focus. Visual designers often overlook this state because it only appears during keyboard navigation. Under WCAG 2.2’s Focus Not Obscured criteria (SC 2.4.11 and 2.4.12), the focused element must not be entirely hidden by sticky headers or overlays. Design focus rings as first-class visual elements, not afterthoughts.
Mapping Transitions, Not Just States
States alone aren’t enough. The real interaction lives in the transitions between states. For each transition, answer these questions:
- What event triggers it? A user click, an API response, a timer, a validation rule.
- Is it instant or time-based? Instant means a straight swap. Time-based means you need an intermediate loading state.
- Can it be interrupted? Can the user cancel an in-flight operation? What happens if they navigate away?
- What side effects happen? An analytics event fires, a toast appears, the form resets.
A transition diagram is a powerful design artifact. Even a rough sketch — states as circles, events as labeled arrows — exposes missing paths that a linear user flow would never reveal.
For example, the “submit form” flow commonly misses:
- What happens if the user clicks submit, the request is in-flight, and they close the tab?
- What happens if the success state resolves but the user has already navigated away?
- What happens on a network timeout versus a server 500 error? Are those the same error state, or do they need different recovery guidance?
Do
Define transitions for both the happy path AND every failure mode before designing visuals. Explicitly map: idle to loading to success, idle to loading to error, and what re-triggers loading from the error state.
Don't
Design only the default and success states, then treat all failure states as edge cases to handle later. Those edge cases become the bugs users report most, and retrofitting states into a shipped component is expensive.
Applying This to Real Components
Toggle / Switch
A toggle has deceptively more states than “on” and “off”:
off-idle— default uncheckedon-idle— checkedoff-focused/on-focused— keyboard-focused variants of eachloading— async operation in progress (common in settings that save remotely)error— the toggle tried to change but the save faileddisabled-off/disabled-on— read-only variants
The async loading state is the one teams forget most often. If toggling “Email notifications” immediately fires a network request, there’s a window where the toggle shows an optimistic state that hasn’t been confirmed yet. What does the UI show during that window? What happens if the request fails? The state machine forces these questions before any code is written.
Multi-Step Form
Each step is its own mini state machine. The form wizard wrapping them also has its own:
step-N-incomplete/step-N-valid/step-N-errorfor each steptransitioningbetween stepssubmittingthe whole formsubmitted-success/submitted-error
Defining these transitions prevents a classic bug: the user goes back to step 1, changes something, and the form submits stale step-2 data — because the wizard never modeled that going back should invalidate later steps.
Data Table with Filters
Tables are notorious for missing states. The minimum set:
loading-initial— first page loadloading-filter-change— a filter was applied, awaiting resultsempty-no-data— the data source has no records at allempty-no-results— records exist but none match the current filtererror-fetch-failed— network or server errorpopulated— normal state with resultspopulated-partial— some rows loaded, more available via pagination
The critical distinction is between empty-no-data and empty-no-results. The first calls for an action to add data. The second calls for clearing the filters. Conflating them leaves users unsure why the table is empty and what to do about it.
State Machines in Design Tooling
Modern design workflows treat state machines as first-class deliverables.
Component variants in Figma map directly to states. A well-organized component uses variant properties that match state machine states: variant = default / loading / error / success, disabled = true / false, focused = true / false. This isn’t just organization — it is the state machine expressed inside the design file.
Prototype interactions in Figma can wire state transitions visually. An on-click trigger moving from the idle variant to loading to success after a delay is a runnable state machine in miniature. It lets you test transitions before engineering begins.
Storybook stories should have one story per state. If you build a Button component, the story list should read: Default, Loading, Success, Error, Disabled, Focused — not just Primary, Secondary, Large. State-driven stories are living documentation. They keep design and engineering in sync far more reliably than a static PDF or an isolated Figma file.
Communicating State Transitions with Motion
State transitions are moments of change, and motion is the language of change. Use purposeful, spring-based motion with motion tokens. Always respect prefers-reduced-motion. Avoid animating layout-affecting properties like width, height, top, or left. Stick to compositor-friendly transform and opacity.
Each transition type has its own motion character:
- Entering a loading state — a quick fade or scale-in of the skeleton or spinner at roughly 80-100ms. It signals responsiveness and confirms the click registered.
- Error transitions — a brief shake or wobble under 300ms. It adds emphasis without being alarming.
- Success transitions — a smooth morph from spinner to checkmark. It communicates continuity: the user’s action resolved, so the UI resolves with it.
- Disabled state — no animation needed. This is a static condition, not a transition event.
Every motion choice should reinforce the state’s meaning: urgency, resolution, denial, progress.
Why This Eliminates Entire Bug Categories
State machines prevent a class of bugs called impossible states — combinations of UI conditions that should never coexist, but do because nobody defined the boundary between them.
Classic examples:
- A form showing both a success banner and an error message at the same time.
- A button that is disabled AND showing a loading spinner — two “inactive” signals competing.
- Two modals open simultaneously because the app never modeled that they’re mutually exclusive.
- A list item showing both “pending” and “confirmed” labels because an optimistic update and a server response hit different code paths.
If your state machine says success and error are terminal states that can’t coexist, it becomes structurally impossible to design or build a UI that shows both at once. The constraint is encoded in the model — not left to individual developer judgment in any given sprint.
Handoff Checklist for Interactive Components
Before a component leaves your design file, run through this list:
- Every state enumerated and named (use consistent naming across the system)
- A design mockup exists for every state, not just the default
- Transition triggers defined for each state change
- Error states have specific messages and a concrete recovery action
- Empty states have a call to action or an explanation
- Loading states use the appropriate pattern (skeleton vs. spinner based on duration and content density)
- Disabled states communicate why via label, tooltip, or surrounding context
- Focus state designed to WCAG 2.2 focus-not-obscured requirements
- Motion tokens applied to transitions; a
prefers-reduced-motionalternative is defined - Storybook stories (or equivalent) planned for each state