Hierarchical States
Nested compound states — organize complex flows without spaghetti.
Hierarchical (compound) states let you nest states inside other states, creating a tree structure. Instead of a flat explosion of states with duplicated transitions, you organize related states under a parent — and the parent’s transitions automatically apply to all its children.
What Are Hierarchical States?
A compound state is a state that contains its own child states. When the machine is “in” the parent state, it is always in exactly one of its children. The parent can define transitions that catch events from any child, eliminating repetitive transition definitions.
Think of it like folders in a file system: a file is always inside a folder, and operations on the folder affect everything inside it.
┌──────────────────── loggedIn ────────────────────┐
│ │
│ ┌───────────┐ VIEW_PROFILE ┌────────────┐ │
│ │ dashboard │ ──────────────► │ profile │ │
│ │ (init) │ ◄────────────── │ │ │
│ └───────────┘ BACK └────────────┘ │
│ │ │
│ VIEW_SETTINGS │
│ ▼ │
│ ┌──────────┐ │
│ │ settings │ │
│ └──────────┘ │
│ │
└────────── LOGOUT ─────────────────────────────────┘
│
▼
┌──────────────────┐
│ loggedOut │
└──────────────────┘
The LOGOUT event on the parent loggedIn catches the event no matter which child is active — dashboard, profile, or settings. This is the power of hierarchy.
JSON Example: Authentication Flow
{
"id": "auth",
"initial": "loggedOut",
"states": {
"loggedOut": {
"on": {
"LOGIN": "loggedIn"
}
},
"loggedIn": {
"initial": "dashboard",
"states": {
"dashboard": {
"on": {
"VIEW_PROFILE": "profile",
"VIEW_SETTINGS": "settings"
}
},
"profile": {
"on": {
"BACK": "dashboard"
}
},
"settings": {
"on": {
"BACK": "dashboard"
}
}
},
"on": {
"LOGOUT": "loggedOut"
}
}
}
}
from xstate_statemachine import create_machine, SyncInterpreter
config = {
"id": "auth",
"initial": "loggedOut",
"states": {
"loggedOut": {"on": {"LOGIN": "loggedIn"}},
"loggedIn": {
"initial": "dashboard",
"states": {
"dashboard": {
"on": {
"VIEW_PROFILE": "profile",
"VIEW_SETTINGS": "settings"
}
},
"profile": {"on": {"BACK": "dashboard"}},
"settings": {"on": {"BACK": "dashboard"}}
},
"on": {"LOGOUT": "loggedOut"}
}
}
}
machine = create_machine(config)
interp = SyncInterpreter(machine).start()
print(interp.active_state_ids)
# {'auth.loggedOut'}
interp.send("LOGIN")
print(interp.active_state_ids)
# {'auth.loggedIn.dashboard'} — entered loggedIn, then its initial child
interp.send("VIEW_PROFILE")
print(interp.active_state_ids)
# {'auth.loggedIn.profile'}
# LOGOUT works from ANY child state
interp.send("LOGOUT")
print(interp.active_state_ids)
# {'auth.loggedOut'}
interp.stop()
Key behavior: The LOGOUT event is defined on the parent loggedIn — it fires regardless of whether the user is on dashboard, profile, or settings. Without hierarchy, you’d need a LOGOUT transition on every single child state.
Initial Child State
Every compound state must specify which child to enter first using the initial field:
{
"loggedIn": {
"initial": "dashboard",
"states": {
"dashboard": {},
"profile": {},
"settings": {}
}
}
}
When the machine transitions to loggedIn, it automatically enters dashboard (the initial child). The machine is never “just” in loggedIn — it’s always in loggedIn.dashboard, loggedIn.profile, or loggedIn.settings.
Warning: Forgetting
initialon a compound state will raise anInvalidConfigError.
Nested Transitions
Child-to-Child Transitions
Children can transition between each other freely:
{
"loggedIn": {
"initial": "dashboard",
"states": {
"dashboard": {
"on": {"VIEW_PROFILE": "profile"}
},
"profile": {
"on": {"BACK": "dashboard"}
}
}
}
}
Parent-Level Transitions (Catch-All)
Transitions defined on the parent apply to all children. If a child doesn’t handle an event, it bubbles up to the parent:
{
"loggedIn": {
"initial": "dashboard",
"states": {
"dashboard": {
"on": {"REFRESH": {"actions": "loadDashboard"}}
},
"profile": {},
"settings": {}
},
"on": {
"LOGOUT": "loggedOut",
"SHOW_HELP": {"actions": "openHelpPanel"}
}
}
}
REFRESHis only handled indashboard— it won’t fire fromprofileorsettingsLOGOUTandSHOW_HELPwork from any child state, because they’re on the parent
Event Bubbling
When an event arrives, the machine checks the current leaf state first. If it doesn’t handle the event, the event bubbles up to the parent, then the grandparent, and so on — just like DOM events in a browser.
# Machine is in auth.loggedIn.profile
interp.send("SHOW_HELP")
# 1. profile doesn't handle SHOW_HELP → bubble up
# 2. loggedIn handles SHOW_HELP → fires openHelpPanel action
Targeting Specific Child States
From outside the hierarchy, you can target a specific child directly by name:
{
"loggedOut": {
"on": {
"LOGIN": "loggedIn",
"LOGIN_TO_SETTINGS": "settings"
}
}
}
Note: When targeting a child state directly, the machine still enters the parent first (firing its entry actions), then enters the specified child.
Deep Nesting (3+ Levels)
Hierarchical states can be nested to any depth:
{
"id": "deepNest",
"initial": "app",
"states": {
"app": {
"initial": "main",
"states": {
"main": {
"initial": "home",
"states": {
"home": {
"on": {"VIEW_DETAIL": "detail"}
},
"detail": {
"on": {"BACK": "home"}
}
}
}
},
"on": {
"CRASH": "error"
}
},
"error": {
"on": {"RESTART": "app"}
}
}
}
from xstate_statemachine import create_machine, SyncInterpreter
config = {
"id": "deepNest",
"initial": "app",
"states": {
"app": {
"initial": "main",
"states": {
"main": {
"initial": "home",
"states": {
"home": {"on": {"VIEW_DETAIL": "detail"}},
"detail": {"on": {"BACK": "home"}}
}
}
},
"on": {"CRASH": "error"}
},
"error": {"on": {"RESTART": "app"}}
}
}
machine = create_machine(config)
interp = SyncInterpreter(machine).start()
print(interp.active_state_ids)
# {'deepNest.app.main.home'} — drills down through all initial states
interp.send("VIEW_DETAIL")
print(interp.active_state_ids)
# {'deepNest.app.main.detail'}
# CRASH bubbles up from detail → main → app (which handles it)
interp.send("CRASH")
print(interp.active_state_ids)
# {'deepNest.error'}
interp.stop()
Tip: Keep nesting to 2-3 levels. Deeper than that usually means your machine should be split into separate machines using services/actors.
State ID Format
Every state gets a fully qualified ID: "machineId.parent.child.grandchild". This dot-separated path uniquely identifies each state in the tree:
| State | Fully Qualified ID |
|---|---|
| loggedOut | auth.loggedOut |
| dashboard | auth.loggedIn.dashboard |
| profile | auth.loggedIn.profile |
| settings | auth.loggedIn.settings |
interp.send("LOGIN")
print(interp.active_state_ids)
# {'auth.loggedIn.dashboard'}
# Check if we're logged in (regardless of which child)
is_logged_in = any(
sid.startswith("auth.loggedIn")
for sid in interp.active_state_ids
)
print(is_logged_in) # True
Pythonic Hierarchical States
Using State with states=[]
from xstate_statemachine import State, StateMachine, SyncInterpreter
class AuthMachine(StateMachine):
machine_id = "auth"
logged_out = State("loggedOut", initial=True)
logged_in = State("loggedIn", states=[
State("dashboard", initial=True,
on={"VIEW_PROFILE": "profile", "VIEW_SETTINGS": "settings"}),
State("profile", on={"BACK": "dashboard"}),
State("settings", on={"BACK": "dashboard"}),
])
login = logged_out.to(logged_in, event="LOGIN")
logout = logged_in.to(logged_out, event="LOGOUT")
machine = AuthMachine.create_machine()
interp = SyncInterpreter(machine).start()
interp.send("LOGIN")
print(interp.active_state_ids)
# {'auth.loggedIn.dashboard'}
interp.send("VIEW_SETTINGS")
print(interp.active_state_ids)
# {'auth.loggedIn.settings'}
interp.send("LOGOUT")
print(interp.active_state_ids)
# {'auth.loggedOut'}
interp.stop()
Using MachineBuilder.child_states()
from xstate_statemachine import MachineBuilder, SyncInterpreter
machine = (
MachineBuilder("auth")
.state("loggedOut", initial=True)
.state("loggedIn")
.child_states("loggedIn", initial="dashboard", states={
"dashboard": {
"on": {
"VIEW_PROFILE": "profile",
"VIEW_SETTINGS": "settings"
}
},
"profile": {"on": {"BACK": "dashboard"}},
"settings": {"on": {"BACK": "dashboard"}},
})
.transition("loggedOut", "LOGIN", "loggedIn")
.transition("loggedIn", "LOGOUT", "loggedOut")
.build()
)
interp = SyncInterpreter(machine).start()
interp.send("LOGIN")
print(interp.active_state_ids)
# {'auth.loggedIn.dashboard'}
interp.stop()
Using Functional API
from xstate_statemachine import State, build_machine, SyncInterpreter
logged_out = State("loggedOut", initial=True, on={"LOGIN": "loggedIn"})
logged_in = State("loggedIn", states=[
State("dashboard", initial=True,
on={"VIEW_PROFILE": "profile", "VIEW_SETTINGS": "settings"}),
State("profile", on={"BACK": "dashboard"}),
State("settings", on={"BACK": "dashboard"}),
], on={"LOGOUT": "loggedOut"})
machine = build_machine(id="auth", states=[logged_out, logged_in])
interp = SyncInterpreter(machine).start()
interp.send("LOGIN")
interp.send("VIEW_PROFILE")
print(interp.active_state_ids)
# {'auth.loggedIn.profile'}
interp.stop()
onDone for Compound States
When a compound state’s child reaches a final state, a done.state.* event fires on the parent. Use onDone to react:
{
"id": "workflow",
"initial": "processing",
"states": {
"processing": {
"initial": "step1",
"states": {
"step1": {
"on": {"NEXT": "step2"}
},
"step2": {
"on": {"NEXT": "complete"}
},
"complete": {
"type": "final"
}
},
"onDone": "finished"
},
"finished": {
"type": "final"
}
}
}
from xstate_statemachine import create_machine, SyncInterpreter
config = {
"id": "workflow",
"initial": "processing",
"states": {
"processing": {
"initial": "step1",
"states": {
"step1": {"on": {"NEXT": "step2"}},
"step2": {"on": {"NEXT": "complete"}},
"complete": {"type": "final"}
},
"onDone": "finished"
},
"finished": {"type": "final"}
}
}
machine = create_machine(config)
interp = SyncInterpreter(machine).start()
print(interp.active_state_ids)
# {'workflow.processing.step1'}
interp.send("NEXT") # step1 → step2
interp.send("NEXT") # step2 → complete (final) → triggers onDone → finished
print(interp.active_state_ids)
# {'workflow.finished'}
interp.stop()
Mixing Hierarchy with Guards
Guards work normally inside nested states:
{
"checkout": {
"initial": "cart",
"states": {
"cart": {
"on": {
"PROCEED": [
{"target": "payment", "guard": "cartNotEmpty"},
{"target": "cart", "actions": "showEmptyError"}
]
}
},
"payment": {}
}
}
}
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
config = {
"id": "shop",
"initial": "checkout",
"states": {
"checkout": {
"initial": "cart",
"states": {
"cart": {
"on": {
"PROCEED": [
{"target": "payment", "guard": "cartNotEmpty"},
{"target": "cart", "actions": "showEmptyError"}
]
}
},
"payment": {
"on": {"PAY": "confirmation"}
},
"confirmation": {"type": "final"}
},
"on": {"CANCEL": "cancelled"}
},
"cancelled": {}
}
}
class ShopLogic(MachineLogic):
def cartNotEmpty(self, context, event):
return len(context.get("items", [])) > 0
def showEmptyError(self, interpreter, context, event, action_def):
print("Cart is empty!")
machine = create_machine(config, logic=ShopLogic())
interp = SyncInterpreter(machine, context={"items": []}).start()
interp.send("PROCEED")
print(interp.active_state_ids)
# {'shop.checkout.cart'} — guard blocked, showed error
interp.stop()
Mixing Hierarchy with Actions
Entry and exit actions fire in the correct order when entering/exiting nested states:
{
"loggedIn": {
"entry": "loadUserSession",
"exit": "cleanupSession",
"initial": "dashboard",
"states": {
"dashboard": {
"entry": "loadDashboardData",
"exit": "clearDashboardCache"
},
"profile": {
"entry": "loadProfileData"
}
}
}
}
When transitioning from loggedOut to loggedIn:
loadUserSession(entry onloggedIn)loadDashboardData(entry ondashboard, the initial child)
When transitioning from dashboard to profile:
clearDashboardCache(exit ondashboard)loadProfileData(entry onprofile)
When transitioning via LOGOUT:
- Exit current child (e.g.,
profile) cleanupSession(exit onloggedIn)
Mixing Hierarchy with Services
Services (invoked operations) work inside nested states:
{
"loggedIn": {
"initial": "loading",
"states": {
"loading": {
"invoke": {
"src": "fetchUserData",
"onDone": {
"target": "dashboard",
"actions": "storeUserData"
},
"onError": "error"
}
},
"dashboard": {},
"error": {
"on": {"RETRY": "loading"}
}
}
}
}
The service fetchUserData is invoked when loading is entered. On success, the machine moves to dashboard (within loggedIn). On failure, it moves to error (also within loggedIn).
Complete Example: E-Commerce Checkout Flow
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
config = {
"id": "checkout",
"initial": "cart",
"context": {
"items": [],
"shippingAddress": None,
"paymentMethod": None,
"orderId": None
},
"states": {
"cart": {
"on": {
"ADD_ITEM": {"actions": "addItem"},
"REMOVE_ITEM": {"actions": "removeItem"},
"CHECKOUT": {
"target": "shipping",
"guard": "cartNotEmpty"
}
}
},
"shipping": {
"initial": "entering",
"states": {
"entering": {
"on": {
"SET_ADDRESS": {
"target": "validating",
"actions": "setAddress"
}
}
},
"validating": {
"invoke": {
"src": "validateAddress",
"onDone": "confirmed",
"onError": {
"target": "entering",
"actions": "showAddressError"
}
}
},
"confirmed": {
"type": "final"
}
},
"onDone": "payment",
"on": {"BACK": "cart"}
},
"payment": {
"initial": "selecting",
"states": {
"selecting": {
"on": {
"SET_PAYMENT": {
"target": "ready",
"actions": "setPayment"
}
}
},
"ready": {
"type": "final"
}
},
"onDone": "processing",
"on": {"BACK": "shipping"}
},
"processing": {
"invoke": {
"src": "submitOrder",
"onDone": {
"target": "confirmation",
"actions": "storeOrderId"
},
"onError": {
"target": "payment",
"actions": "showPaymentError"
}
}
},
"confirmation": {
"type": "final"
}
}
}
class CheckoutLogic(MachineLogic):
def addItem(self, interpreter, context, event, action_def):
context["items"].append(event.data.get("item", "unknown"))
def removeItem(self, interpreter, context, event, action_def):
item = event.data.get("item")
if item in context["items"]:
context["items"].remove(item)
def cartNotEmpty(self, context, event):
return len(context["items"]) > 0
def setAddress(self, interpreter, context, event, action_def):
context["shippingAddress"] = event.data.get("address")
def validateAddress(self, interpreter, context, event):
return {"valid": True}
def showAddressError(self, interpreter, context, event, action_def):
print("Invalid shipping address")
def setPayment(self, interpreter, context, event, action_def):
context["paymentMethod"] = event.data.get("method")
def submitOrder(self, interpreter, context, event):
return {"orderId": "ORD-12345"}
def storeOrderId(self, interpreter, context, event, action_def):
context["orderId"] = event.data.get("orderId")
def showPaymentError(self, interpreter, context, event, action_def):
print("Payment failed, please try again")
machine = create_machine(config, logic=CheckoutLogic())
interp = SyncInterpreter(machine).start()
# Add items to cart
interp.send({"type": "ADD_ITEM", "item": "Widget"})
interp.send({"type": "ADD_ITEM", "item": "Gadget"})
print(interp.context["items"])
# ['Widget', 'Gadget']
# Start checkout
interp.send("CHECKOUT")
print(interp.active_state_ids)
# {'checkout.shipping.entering'}
# Set shipping address
interp.send({"type": "SET_ADDRESS", "address": "123 Main St"})
# validateAddress runs → succeeds → confirmed (final) → onDone → payment
print(interp.active_state_ids)
# {'checkout.payment.selecting'}
# Set payment
interp.send({"type": "SET_PAYMENT", "method": "credit_card"})
# ready (final) → onDone → processing → submitOrder → confirmation
print(interp.active_state_ids)
# {'checkout.confirmation'}
print(interp.context["orderId"])
# ORD-12345
interp.stop()
Complete Example: Multi-Step Form Wizard
from xstate_statemachine import State, StateMachine, SyncInterpreter, action, guard
class FormWizard(StateMachine):
machine_id = "wizard"
initial_context = {
"personal": {},
"address": {},
"preferences": {},
"currentStep": 1
}
# Top-level states
filling = State("filling", initial=True, states=[
State("personal", initial=True, on={
"NEXT": {"target": "address", "guard": "personalComplete"}
}),
State("address", on={
"PREV": "personal",
"NEXT": {"target": "preferences", "guard": "addressComplete"}
}),
State("preferences", on={
"PREV": "address",
"NEXT": "done"
}),
State("done", final=True),
], on={
"UPDATE": {"actions": "updateField"}
}, on_done="reviewing")
reviewing = State("reviewing", on={
"EDIT": "filling",
"SUBMIT": "submitting"
})
submitting = State("submitting", invoke={
"src": "submitForm",
"onDone": "success",
"onError": {"target": "reviewing", "actions": "showError"}
})
success = State("success", final=True)
# Guards
@guard
def personal_complete(self, context, event):
p = context.get("personal", {})
return bool(p.get("name") and p.get("email"))
@guard
def address_complete(self, context, event):
a = context.get("address", {})
return bool(a.get("street") and a.get("city"))
# Actions
@action
def update_field(self, interpreter, context, event, action_def):
section = event.data.get("section", "personal")
field = event.data.get("field")
value = event.data.get("value")
if section in context and field:
context[section][field] = value
machine = FormWizard.create_machine()
interp = SyncInterpreter(machine).start()
print(interp.active_state_ids)
# {'wizard.filling.personal'}
# Fill personal info
interp.send({"type": "UPDATE", "section": "personal",
"field": "name", "value": "Alice"})
interp.send({"type": "UPDATE", "section": "personal",
"field": "email", "value": "alice@example.com"})
# Advance to address
interp.send("NEXT")
print(interp.active_state_ids)
# {'wizard.filling.address'}
# Go back
interp.send("PREV")
print(interp.active_state_ids)
# {'wizard.filling.personal'}
interp.stop()