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 initial on a compound state emits a warning at create_machine() time and raises InvalidConfigError when 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"}
    }
  }
}
  • REFRESH is only handled in dashboard — it won’t fire from profile or settings
  • LOGOUT and SHOW_HELP work 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:

  1. loadUserSession (entry on loggedIn)
  2. loadDashboardData (entry on dashboard, the initial child)

When transitioning from dashboard to profile:

  1. clearDashboardCache (exit on dashboard)
  2. loadProfileData (entry on profile)

When transitioning via LOGOUT:

  1. Exit current child (e.g., profile)
  2. cleanupSession (exit on loggedIn)

📞 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, and validateAddress above 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 a UserWarning. Decorate with @guard, @service, or @action to 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()