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.
stateDiagram-v2
[*] --> loggedIn
state loggedIn {
[*] --> dashboard
dashboard --> profile : VIEW_PROFILE
profile --> dashboard : BACK
dashboard --> settings : VIEW_SETTINGS
settings --> dashboard : BACK
}
loggedIn --> loggedOut : LOGOUT
loggedOut --> loggedIn : LOGIN
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 emits a warning atcreate_machine()time and raisesInvalidConfigErrorwhen the interpreter is started (.start()), because entering the compound state has no leaf state to resolve to.
🔀 Nested Transitions
stateDiagram-v2
state app {
state loggedIn {
[*] --> dashboard
dashboard --> profile : GO_PROFILE
profile --> dashboard : BACK
}
loggedIn --> loggedOut : LOGOUT
loggedOut --> loggedIn.profile : DEEP_LINK
}
Three kinds of edge above: child → child (GO_PROFILE), parent-level (LOGOUT, caught from any child), and deep target (DEEP_LINK lands directly on a grandchild).
Child-to-Child Transitions
Children can transition between each other freely:
{
"loggedIn": {
"initial": "dashboard",
"states": {
"dashboard": {
"on": {"VIEW_PROFILE": "profile"}
},
"profile": {
"on": {"BACK": "dashboard"}
}
}
}
}
Relative and Absolute Targets
A target beginning with a dot, such as ".child", resolves into the source state’s own children — matching XState v5. So {target: ".profile"} on dashboard means “my own child profile”, not a sibling of dashboard.
The 0.7.x reading — resolving .child as a sibling of the source, i.e. a child of the source’s parent — is kept as a fallback for machines written against that behavior. Since 0.8.1 every resolution that takes the fallback emits a DeprecationWarning (once per source/target pair) naming the target, the sibling it bound to, and the unambiguous #machine.path spelling — so an existing codebase can find its own ambiguous targets without a flag day. The fallback is removed in 1.0. Set "strictTargets": true on the machine to disable it today and get a hard InvalidConfigError at create_machine() time instead:
{
"id": "m",
"strictTargets": true,
"initial": "loggedIn",
"states": { "loggedIn": {} }
}
An absolute target such as "#machineId.path.to.state" resolves from the machine root regardless of where the transition is defined, and is unaffected by strictTargets.
Bare Targets Are Sibling-Only
As of 0.8.0, a bare target: "someState" (no leading dot, #, or path) resolves only as a sibling of the transition’s source — a child of the source’s own parent. There is no whole-tree search by last id segment anymore.
Before 0.8.0, an unqualified target that didn’t resolve as a sibling fell back to a fuzzy, whole-tree search for any state ending in that name — including states in an unrelated branch or parallel region. A bare target: "filled" declared in one parallel region could silently bind to audit.archive.filled in a completely different region and move it there instead. That fallback was a bug, not a feature, and has been removed; resolution is now strictly lexical (sibling / #id / .child / exact top-level key).
To target a state in another branch or region, use the absolute #machine.path.to.state form:
{
"target": "#myMachine.audit.archive.filled"
}
History States
A history pseudostate remembers which child of a compound state was last active, so a later transition back into the compound state re-enters that child instead of always falling back to the declared initial child. This is useful whenever “resume where you left off” matters more than “start from the top” — for example a media player that should stay paused/playing across a power cycle, or a wizard that should reopen on the step the user was last viewing.
Declare a child with "type": "history" and target it explicitly. The "history" config key controls the depth:
"shallow"(the default) — remembers only the immediate child of the state that declares the history node."deep"— remembers the full chain of active descendants, all the way down to the leaf.
{
"id": "player",
"initial": "off",
"states": {
"off": {
"on": { "POWER": "#player.playing.hist" }
},
"playing": {
"initial": "paused",
"states": {
"paused": { "on": { "PLAY": "running" } },
"running": { "on": { "PAUSE": "paused" } },
"hist": { "type": "history", "history": "shallow" }
},
"on": { "POWER": "off" }
}
}
}
from xstate_statemachine import create_machine, SyncInterpreter
config = {
"id": "player",
"initial": "off",
"states": {
"off": {
"on": {"POWER": "#player.playing.hist"}
},
"playing": {
"initial": "paused",
"states": {
"paused": {"on": {"PLAY": "running"}},
"running": {"on": {"PAUSE": "paused"}},
"hist": {"type": "history", "history": "shallow"}
},
"on": {"POWER": "off"}
}
}
}
machine = create_machine(config)
interp = SyncInterpreter(machine).start()
interp.send("POWER")
print(interp.active_state_ids)
# {'player.playing.paused'}
interp.send("PLAY")
print(interp.active_state_ids)
# {'player.playing.running'}
# Power off, then back on via the history pseudostate
interp.send("POWER")
print(interp.active_state_ids)
# {'player.off'}
interp.send("POWER")
print(interp.active_state_ids)
# {'player.playing.running'} — resumed where we left off, not the declared initial ('paused')
interp.stop()
A history node is never itself an active state — it is only ever a transition target that gets expanded into the remembered child (or the declared initial child, the first time the parent is entered). See services.md for another way to model “resume” behavior across invoked actors.
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",
"context": {"items": []},
"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 cart_not_empty(self, context, event):
return len(context.get("items", [])) > 0
def show_empty_error(self, interpreter, context, event, action_def):
print("Cart is empty!")
machine = create_machine(config, logic=ShopLogic())
interp = SyncInterpreter(machine).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 add_item(self, interpreter, context, event, action_def):
context["items"].append(event.data.get("item", "unknown"))
def remove_item(self, interpreter, context, event, action_def):
item = event.data.get("item")
if item in context["items"]:
context["items"].remove(item)
def cart_not_empty(self, context, event):
return len(context["items"]) > 0
def set_address(self, interpreter, context, event, action_def):
context["shippingAddress"] = event.data.get("address")
def validate_address(self, interpreter, context, event):
return {"valid": True}
def show_address_error(self, interpreter, context, event, action_def):
print("Invalid shipping address")
def set_payment(self, interpreter, context, event, action_def):
context["paymentMethod"] = event.data.get("method")
def submit_order(self, interpreter, context, event):
return {"orderId": "ORD-12345"}
def store_order_id(self, interpreter, context, event, action_def):
context["orderId"] = event.data.get("orderId")
def show_payment_error(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"})
# validate_address 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 → submit_order → confirmation
print(interp.active_state_ids)
# {'checkout.confirmation'}
print(interp.context["orderId"])
# ORD-12345
interp.stop()
Note: Methods like
cartNotEmpty,submitOrder, andvalidateAddressabove are undecorated and inferred by arity. As of 0.8.0, when the arity is ambiguous between roles (e.g. 2 args could be a guard or a 2-arg service; 3 args could be a service or an action),create_machine()emits aUserWarning. Decorate with@guard,@service, or@actionto be explicit and silence the warning.
🧙 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()