Catalog of 12 canonical UI component state machines. Each pattern includes the model, ASCII diagram, invariants, and edge cases.
Pattern 1 — Modal (basic)
State
Description
Terminal
Closed
Modal is not rendered or hidden
No
Opening
CSS transition / animation playing in
No
Open
Modal is visible with full content
No
Closing
CSS transition / animation playing out
No
From
Event
Guard
To
Actions
Closed
TRIGGER
—
Opening
onOpen callback
Opening
—
Open
trapFocus()
Open
DISMISS
canDismiss
Closing
—
Open
CLOSE_BTN
—
Closing
—
Open
ESC
canDismiss
Closing
—
Open
OVERLAY_CLICK
canDismiss
Closing
—
Closing
—
Closed
releaseFocus(), onClose callback
Guard
Expression
Purpose
canDismiss
!props.disableClose && !context.isBlocking
Allow or block dismiss
┌──────────────────────────────────────┐
│ DISMISS / CLOSE_BTN / ESC / │
│ OVERLAY_CLICK │
v (canDismiss) │
┌────────┐ TRIGGER ┌──────────┐ <done> ┌──────┐ │
│ Closed │────────>│ Opening │───────>│ Open │ │
└────────┘ └──────────┘ └──┬───┘ │
│ │
│ DISMISS/ │
│ CLOSE_BTN/ESC/ │
│ OVERLAY_CLICK │
v │
┌────────┐ │
│Closing │──────────┘
└───┬────┘ <done>
│
v
┌────────┐
│ Closed │
└────────┘
Opening and Closing are transient — the component MUST NOT wait for user input in these states
is a machine-internal event, never dispatched from outside
At any point, DISMISS returns the modal to Closed through Closing (or stays in place if canDismiss is false)
Edge case
Handling
TRIGGER while Opening
No-op (event not handled)
TRIGGER while Open
No-op (already open)
Double-click close button
First CLOSE_BTN → Closing; second is no-op
ESC while animating out
No-op (Closing ignores ESC)
Browser back button
Must be handled externally via beforeunload
Pattern 2 — Modal with async content
State
Description
Terminal
Closed
Not rendered
No
Opening
Animating in
No
Loading
Spinner visible, fetching data
No
Success
Content displayed
No
Error
Error message displayed with retry
No
Closing
Animating out
No
From
Event
Guard
To
Actions
Closed
TRIGGER
—
Opening
onOpen callback
Opening
—
Loading
fetchData()
Loading
FETCH_SUCCESS
—
Success
setData(response)
Loading
FETCH_ERROR
—
Error
setError(message)
Success
DISMISS
canDismiss
Closing
—
Error
DISMISS
canDismiss
Closing
—
Error
RETRY
—
Loading
fetchData()
Closing
—
Closed
onClose callback
Same as Pattern 1.
┌────────────────────────────────────────┐
│ DISMISS │
v (canDismiss) │
┌────────┐ TRIGGER ┌──────────┐ ┌──────────┐ │
│ Closed │────────>│ Opening │──>│ Loading │ │
└────────┘ └──────────┘ └────┬─────┘ │
│ │
┌────────┼──────┐ │
v v │ │
┌─────────┐ ┌─────────┐ │ │
│ Success │ │ Error │ │ │
└────┬────┘ └────┬────┘ │ │
│ │ │ │
│ RETRY │ │ │
│ v │ │
│ ┌─────────┐│ │
│ │ Loading ││ │
│ └─────────┘│ │
└───────┬──────────┘ │
v │
┌────────┐ │
│Closing │◄───────────────┘
└───┬────┘ DISMISS
│
│ <done>
v
┌────────┐
│ Closed │
└────────┘
The component MUST NOT be dismissible while Loading (unless explicitly specified)
RETRY from Error re-enters Loading, not Opening
FETCH_SUCCESS and FETCH_ERROR are only valid in Loading
Edge case
Handling
FETCH_SUCCESS arrives after user dismissed
Ignored (no handler in Closing)
Network timeout
FETCH_ERROR dispatched with timeout message
Stale response (two rapid TRIGGERs)
Cancel previous request; ignore late response via request ID guard
TRIGGER while already open
No-op; user must close first
Pattern 3 — Toggle (simple)
State
Description
Terminal
Off
Off state (default unchecked)
No
On
On state (checked)
No
From
Event
Guard
To
Actions
Off
TOGGLE
—
On
onChange(true)
On
TOGGLE
—
Off
onChange(false)
TOGGLE (onChange(false))
┌──────┐ ◄─────────────────── ┌──────┐
│ Off │ │ On │
└──────┘ ───────────────────► └──────┘
TOGGLE (onChange(true))
The component is always in exactly one of two states
Every TOGGLE event changes the state
Edge case
Handling
TOGGLE while disabled
Guard: isEnabled must be true
Controlled vs uncontrolled
Controlled: state comes from props; TOGGLE only calls onChange
Pattern 4 — Toggle (async with optimistic UI)
State
Description
Terminal
Off
Toggle is off
No
PendingOn
Optimistically showing On, awaiting server
No
On
Server confirmed On
No
PendingOff
Optimistically showing Off, awaiting server
No
Error
Server rejected the change
No
From
Event
Guard
To
Actions
Off
TOGGLE
—
PendingOn
setOptimistic(true), patchServer(true)
PendingOn
CONFIRM
—
On
onChange(true)
PendingOn
REJECT
—
Off
rollback(false), setError(message)
On
TOGGLE
—
PendingOff
setOptimistic(false), patchServer(false)
PendingOff
CONFIRM
—
Off
onChange(false)
PendingOff
REJECT
—
On
rollback(true), setError(message)
Error
DISMISS
—
Off
clearError()
Error
RETRY
—
PendingOn
patchServer(true)
Off
RETRY
—
PendingOn
patchServer(true)
Guard
Expression
Purpose
isOnline
navigator.onLine === true
Prevent toggle when offline
┌──────────────────────────────────────────────┐
│ TOGGLE + isOnline │
v │
┌────────┐ ┌────────────┐ CONFIRM ┌────────┐ │
│ Off │──────>│ PendingOn │────────>│ On │ │
└───┬────┘ └──────┬─────┘ └───┬────┘ │
│ │ REJECT │ │
│ v │ │
│ ┌───────┐ │ │
│ │ Error │ │ │
│ └───┬───┘ │ │
│ │ TOGGLE │ │
│ │ (RETRY) │ │
│ v │ │
│ ┌────────────┐ │ │
│ │ PendingOn │ │ │
│ └────────────┘ │ │
│ │ │
│ ┌─────────────────────────┘ │
│ v TOGGLE + isOnline │
│ ┌────────────┐ CONFIRM ┌────────┐ │
│ │ PendingOff │────────>│ Off │────────────────┘
│ └──────┬─────┘ └────────┘
│ │ REJECT
│ v
│ ┌───────┐
└──────>│ Error │
└───────┘
The component is NEVER in PendingOn and On simultaneously
The component is NEVER in PendingOff and Off simultaneously
REJECT from PendingOn always returns to Off (rollback)
REJECT from PendingOff always returns to On (rollback)
User can RETRY from Error to retry the failed operation
Edge case
Handling
User toggles while pending
TOGGLE is not handled in PendingOn/PendingOff
CONFIRM arrives late after user left page
Ignored (component unmounted)
Rapid toggles (Off→PendingOn→REJECT→Off→TOGGLE)
Each TOGGLE starts fresh; pending requests are cancelled
Offline toggle
Guard isOnline blocks the transition; show offline message
Pattern 5 — Form (single-step)
State
Description
Terminal
Idle
Form rendered with default/empty values
No
Dirty
User has modified at least one field
No
Validating
Client-side validation in progress
No
Submitting
Sending data to server
No
Success
Submission confirmed
Yes (transient to Idle)
Error
Server returned an error
No
From
Event
Guard
To
Actions
Idle
CHANGE
hasChanges
Dirty
updateField(name, value)
Dirty
CHANGE
—
Dirty
updateField(name, value)
Dirty
SUBMIT
isFormValid
Submitting
onSubmit(formData)
Dirty
SUBMIT
!isFormValid
Validating
validateAll()
Dirty
RESET
—
Idle
resetForm()
Validating
VALIDATION_PASS
—
Submitting
onSubmit(formData)
Validating
VALIDATION_FAIL
—
Dirty
setErrors(errors)
Submitting
SUBMIT_SUCCESS
—
Success
onSuccess(response)
Submitting
SUBMIT_ERROR
—
Error
setServerError(message)
Success
—
Idle
resetForm()
Error
CHANGE
—
Dirty
clearServerError(), updateField()
Error
RETRY
isFormValid
Submitting
onSubmit(formData)
Guard
Expression
Purpose
hasChanges
context.dirtyFields.size > 0
Distinguish Idle from Dirty
isFormValid
validationErrors.size === 0
Allow or block submission
┌────────────────────────────────────────────┐
│ CHANGE (isFormValid) │
v │
┌──────┐ CHANGE ┌────────┐ SUBMIT ┌────────────┐ SUBMIT_SUCCESS┐│
│ Idle │───────>│ Dirty │───────>│ Submitting │───────────────┘│
└──────┘ └───┬────┘ └─────┬──────┘ │
▲ │ │ │
│ │ SUBMIT + !valid │ SUBMIT_ERROR │
│ v v │
│ RESET ┌────────────┐ ┌──────────┐ │
│ │ Validating │ │ Error │──────────────────┘
│ └───────┬────┘ └────┬─────┘ CHANGE (clears)
│ │ │ RETRY (isFormValid)
│ VALIDATION_FAIL v
│ │ ┌────────────┐
│ └─────────>│ Dirty │
│ └────────────┘
│
└────────────────────────────────────────── <done> from Success
(auto-reset)
SUBMIT from Dirty only proceeds if isFormValid passes; otherwise goes to Validating
Validating is a transient state — the component never waits for user input there
Success is a terminal state that auto-transitions to Idle after
CHANGE from Error clears server errors and returns to Dirty
Edge case
Handling
Double-click submit
First SUBMIT → Submitting; second is no-op (not handled)
Browser refresh during submit
Form data lost; Idle on mount
Server validation errors
Set field-level errors, return to Dirty for user to fix
Stale form (user changed tab for 1 hour)
No timeout in model; add IdleTimer guard if needed
Pattern 6 — Form (multi-step)
This is a hierarchical FSM. The root machine has a state per step, and each step is itself a mini-form machine.
MultiStepForm (root)
├── Step1 (compound)
│ ├── Idle*
│ ├── Dirty
│ └── Validating
├── Step2 (compound)
│ ├── Idle*
│ ├── Dirty
│ └── Validating
├── Step3 (compound)
│ ├── Idle*
│ ├── Dirty
│ └── Validating
└── Completed (final)
From
Event
Guard
To
Actions
Step1
NEXT
isStepValid
Step2
saveStep1(data)
Step2
PREV
—
Step1
restoreStep1()
Step2
NEXT
isStepValid
Step3
saveStep2(data)
Step3
PREV
—
Step2
restoreStep2()
Step3
SUBMIT
isStepValid && isLastStep
Submitting
submitAll()
Each step's internal model follows Pattern 5 (single-step form) with Idle → Dirty → Validating, but without the Submitting/Success/Error states (those are at the root level).
The component is in exactly one Step at any time
NEXT from StepN goes to StepN+1; PREV from StepN goes to StepN-1
PREV from Step1 is not handled (no-op or disabled)
Each step saves its data on NEXT (not on SUBMIT)
The final step dispatches SUBMIT instead of NEXT
Edge case
Handling
User refreshes on Step2
Restore Step1 from saved data; Step2 re-initializes
Browser back button
Must sync with PREV event or block navigation
Step validation fails
Stay in current step, show validation errors
PDF upload on Step2 while filling Step3
Not possible — component is in Step3
Pattern 7 — Onboarding wizard
State
Description
Terminal
Welcome
Introduction screen
No
Step1
First configuration step
No
Step2
Second configuration step
No
Step3
Third configuration step
No
Completed
All steps done
Yes
Skipped
User opted out
Yes
From
Event
Guard
To
Actions
Welcome
START
—
Step1
trackEvent('wizard_started')
Welcome
SKIP
—
Skipped
trackEvent('wizard_skipped')
Step1
NEXT
isStepValid
Step2
save(data)
Step1
SKIP
isSkippable
Step2
trackEvent('step1_skipped')
Step2
NEXT
isStepValid
Step3
save(data)
Step2
PREV
—
Step1
restoreStep1()
Step2
SKIP
isSkippable
Step3
trackEvent('step2_skipped')
Step3
FINISH
isStepValid
Completed
submitAll(), trackEvent('completed')
Step3
PREV
—
Step2
restoreStep2()
Guard
Expression
Purpose
isSkippable
steps[step].canSkip === true
Allow skipping non-required steps
START
┌──────────┐ ────────────> ┌────────┐ NEXT (isValid) ┌────────┐
│ Welcome │ │ Step1 │ ────────────────> │ Step2 │
└─────┬────┘ └───┬────┘ └───┬────┘
│ SKIP │ SKIP (isSkippable) │ SKIP
v v v
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Skipped │ │ Skipped │ │ Skipped │
└──────────┘ └──────────┘ └──────────┘
│ FINISH
v
┌───────────┐
│ Completed │
└───────────┘
Skipped is a leaf state — once skipped, the wizard does not return to active steps
PREV from Step1 is not handled
SKIP on a non-skippable step is blocked by isSkippable guard
Completed and Skipped are terminal states
Edge case
Handling
User presses SKIP on mandatory step
Guard isSkippable blocks it
User restarts after completing
New session; start fresh from Welcome
Step validation fails on FINISH
Stay in Step3, show errors
Pattern 8 — Dropdown / Combobox
State
Description
Terminal
Closed
Dropdown not visible
No
Opening
Animating in
No
Open
Dropdown visible, no search
No
Searching
User is typing to filter
No
Closing
Animating out
No
From
Event
Guard
To
Actions
Closed
TRIGGER
—
Opening
—
Opening
—
Open
—
Open
CLOSE
—
Closing
—
Open
SELECT
—
Closing
onChange(item)
Open
SEARCH
hasInput
Searching
setQuery(value)
Searching
CLOSE
—
Closing
—
Searching
SELECT
—
Closing
onChange(item)
Searching
SEARCH
—
Searching
setQuery(value)
Searching
CLEAR
—
Open
clearQuery()
Searching
isQueryStale
Searching
showEmptyState()
Closing
—
Closed
—
Any
CLICK_OUTSIDE
isOpen
Closing
—
Guard
Expression
Purpose
hasInput
event.value.length > 0
Only search with non-empty input
isOpen
state in (Open, Searching)
CLICK_OUTSIDE guard
isQueryStale
lastQuery !== currentQuery
Debounce stale results
CLICK_OUTSIDE is handled in any open state
SELECT always transitions to Closing (immediate close on selection)
CLEAR from Searching returns to Open (no search query)
Searching is a stable state (user can type freely)
Edge case
Handling
Click outside while animating in
Not handled; wait for
Select while loading search results
Ignore late results; SELECT references selection, not search
Empty search results
Stay in Searching; show "No results" UI
Keyboard ESC
Mapped to CLOSE event
Pattern 9 — Infinite scroll
State
Description
Terminal
Idle
Initial state, first page loaded or empty
No
LoadingMore
Fetching next page
No
EndOfList
All items fetched
Yes
Error
Failed to fetch next page
No
From
Event
Guard
To
Actions
Idle
SCROLL_NEAR_BOTTOM
hasMorePages
LoadingMore
fetchNextPage()
Idle
SCROLL_NEAR_BOTTOM
!hasMorePages
EndOfList
—
LoadingMore
FETCH_SUCCESS
hasMorePages
Idle
appendItems(data)
LoadingMore
FETCH_SUCCESS
!hasMorePages
EndOfList
appendItems(data)
LoadingMore
FETCH_ERROR
—
Error
setError(message)
Error
RETRY
—
LoadingMore
fetchNextPage()
Guard
Expression
Purpose
hasMorePages
context.currentPage < context.totalPages
End condition
┌──────┐ SCROLL_NEAR_BOTTOM ┌─────────────┐
│ Idle │───────────────────>│ LoadingMore │
└──┬───┘ └──────┬───────┘
│ │
│ ┌──────────┼──────────┐
│ v v │
│ ┌────────────┐ ┌───────────┐ │
│ │ EndOfList │ │ Error │ │
│ └────────────┘ └─────┬─────┘ │
│ │ RETRY │
│ v │
│ ┌─────────────┐ │
│ (hasMorePages) ───>│ LoadingMore │─┘
└──────────────────────────┘─────────────┘
EndOfList is terminal — no further events transition out of it
FETCH_SUCCESS from LoadingMore always checks hasMorePages to decide next state
SCROLL_NEAR_BOTTOM in LoadingMore is not handled (prevents duplicate fetches)
The component starts in Idle even if data is preloaded
Edge case
Handling
Rapid scroll past threshold
Only first SCROLL_NEAR_BOTTOM fires; subsequent are no-op while LoadingMore
Refresh from top (pull-to-refresh)
Add PullToRefresh state parallel to the main machine
Empty initial state
Idle shows empty state; SCROLL_NEAR_BOTTOM with !hasMorePages → EndOfList
Network error on retry
Stay in Error; allow retry without limit
State
Description
Terminal
Idle
Drop zone / upload button visible
No
Selecting
Native file picker open
No
Validating
Client-side validation (size, type)
No
Uploading
File transfer in progress
No
Success
Upload complete
Yes (transient)
Error
Upload failed or validation failed
No
From
Event
Guard
To
Actions
Idle
SELECT
—
Selecting
openFilePicker()
Idle
DROP
isValidFile
Validating
processDroppedFile(file)
Idle
DROP
!isValidFile
Error
setValidationError(reason)
Selecting
FILE_SELECTED
isValidFile
Validating
processSelectedFile(file)
Selecting
FILE_SELECTED
!isValidFile
Error
setValidationError(reason)
Selecting
CANCEL
—
Idle
—
Validating
VALIDATION_PASS
—
Uploading
uploadFile(file)
Validating
VALIDATION_FAIL
—
Error
setValidationError(reason)
Uploading
UPLOAD_PROGRESS
—
Uploading
setProgress(percent)
Uploading
UPLOAD_SUCCESS
—
Success
onSuccess(response)
Uploading
UPLOAD_ERROR
—
Error
setError(message)
Uploading
CANCEL
—
Idle
cancelUpload()
Success
—
Idle
resetState()
Error
RETRY
—
Uploading
uploadFile(file)
Error
DISMISS
—
Idle
resetState()
Guard
Expression
Purpose
isValidFile
validateType(file) && validateSize(file)
Client-side file validation
UPLOAD_PROGRESS is a self-transition on Uploading that updates progress context. It does not change the state.
Uploading + UPLOAD_PROGRESS → Uploading (setProgress)
Success auto-transitions to Idle after (default 2s delay)
CANCEL from Selecting, Uploading, or Validating always returns to Idle
RETRY from Error re-enters Uploading (not Selecting)
UPLOAD_PROGRESS does not change state — only updates context
Edge case
Handling
File too large during selection
isValidFile false → Error with validation reason
User closes browser during upload
Upload continues in background or cancels on unload
Duplicate file upload
Check in isValidFile or handle in guard
Multiple files
If multi-file: Uploading holds a queue; each file transitions through its own cycle
Drag file over drop zone
This is a hover event, not a state transition. Track via local hover state outside the machine
State
Description
Terminal
Unauthenticated
User has no session
No
Authenticating
Login/signup request in flight
No
Authenticated
User has valid session
No
MfaRequired
2FA challenge required
No
From
Event
Guard
To
Actions
Unauthenticated
LOGIN
hasCredentials
Authenticating
postLogin(credentials)
Unauthenticated
SIGNUP
hasCredentials
Authenticating
postSignup(credentials)
Authenticating
AUTH_SUCCESS
requiresMfa
MfaRequired
setMfaChallenge(challenge)
Authenticating
AUTH_SUCCESS
!requiresMfa
Authenticated
setSession(token, user)
Authenticating
AUTH_ERROR
—
Unauthenticated
setError(message)
MfaRequired
MFA_SUBMIT
hasMfaCode
Authenticating
postMfa(code)
MfaRequired
CANCEL
—
Unauthenticated
cancelMfa()
Authenticated
LOGOUT
—
Unauthenticated
clearSession()
Authenticated
SESSION_EXPIRED
—
Unauthenticated
clearSession(), setError('session_expired')
Authenticated
REFRESH
isSessionStale
Authenticated
refreshToken()
Guard
Expression
Purpose
hasCredentials
email && password
Prevent empty submit
requiresMfa
response.mfaRequired === true
Branch after auth
hasMfaCode
code.length === 6
MFA code completeness
isSessionStale
tokenExpiresAt - now < 300_000
Refresh within 5min of expiry
Authenticated can have its own child machine: Authenticated.Idle, Authenticated.Refreshing if the token refresh is a visible loading state.
AUTH_SUCCESS from Authenticating either goes to MfaRequired or Authenticated — never both
LOGOUT from Authenticated clears all session data
MFA flow always returns to Authenticating (not directly to Authenticated) so the MFA code is verified server-side
SESSION_EXPIRED can fire at any time from Authenticated
Edge case
Handling
Token expires during long form fill
SESSION_EXPIRED → Unauthenticated; form data is lost
Rapid LOGIN/SIGNUP spam
First event goes to Authenticating; subsequent are no-op
MFA code expired
MFA_SUBMIT returns AUTH_ERROR with 'code_expired'
Refresh token fails
SESSION_EXPIRED from Authenticated.Refreshing
Pattern 12 — Toast / Notification
State
Description
Terminal
Hidden
Toast not in DOM
No
Entering
Slide-in / fade-in animation
No
Visible
Toast displayed with message
No
Exiting
Slide-out / fade-out animation
No
From
Event
Guard
To
Actions
Hidden
SHOW
—
Entering
setMessage(text), setType(type)
Entering
—
Visible
—
Visible
DISMISS
canDismiss
Exiting
—
Visible
TIMEOUT
hasTimeout
Exiting
—
Visible
isAutoClose
Exiting
—
Exiting
—
Hidden
onClose callback
Guard
Expression
Purpose
canDismiss
type !== 'critical'
Critical toasts cannot be manually dismissed
hasTimeout
props.duration > 0
Only auto-dismiss if duration is set
isAutoClose
elapsed >= props.duration
Auto-dismiss after duration
┌─────────────────────────────────────────────┐
│ DISMISS (canDismiss) / TIMEOUT / <done> │
v (isAutoClose) │
┌────────┐ SHOW ┌──────────┐ <done> ┌────────┐ │
│ Hidden │───────>│ Entering │───────>│ Visible │ │
└───┬────┘ └──────────┘ └───┬────┘ │
│ │ │
│ │ DISMISS/TIMEOUT │
│ v │
│ ┌────────┐ │
│ │ Exiting│─────────────────┘
│ └───┬────┘ <done>
│ │
└──────────────────────────────────────┘
Entering and Exiting are transient states
DISMISS on a critical toast is blocked by canDismiss guard
TIMEOUT only fires if duration > 0 (hasTimeout guard)
New SHOW while Visible: either queue the toast (see extension) or replace current
For multiple toasts, add a queue context:
Visible + SHOW → Visible (enqueue next toast)
guard: isQueueEnabled
actions: [enqueueToast(next)]
Hidden → Entering
guard: hasQueuedToasts
actions: [dequeueToast()]
Edge case
Handling
User hovers over toast
Pause the auto-close timeout; keep Visible
Toast appears during exiting animation
Queue it; show after from Exiting
Rapid SHOW bursts
Queue all; show sequentially
Critical toast + user dismiss attempt
canDismiss=false → DISMISS is not handled
Summary: Which pattern to use
If the component...
Use pattern
Opens and closes with animation
1 or 2
Has a loading state between open and content
2
Is a simple on/off switch
3
Needs optimistic UI + server confirmation
4
Has a form with validation
5
Has multiple sequential form steps
6
Guides user through setup with skip option
7
Has a searchable list that opens/closes
8
Loads content progressively as user scrolls
9
Handles file selection → validation → upload
10
Manages authentication lifecycle
11
Shows timed notifications
12
This file is part of the state-machine skill. See SKILL.md for the full table of contents.