Recipe: Form Wizard & Multi-Step Onboarding
Manage multi-step forms, user onboarding, back/next navigation, step validation, and draft persistence with xstate-statemachine.
🎯 Statechart Topology
stateDiagram-v2
[*] --> Step1_Profile
Step1_Profile --> Step2_Preferences: NEXT [isProfileValid]
Step2_Preferences --> Step1_Profile: BACK
Step2_Preferences --> Step3_Billing: NEXT [isPrefsValid]
Step3_Billing --> Step2_Preferences: BACK
Step3_Billing --> Review: NEXT [isBillingValid]
Review --> Completed: SUBMIT
Completed --> [*]
🛠️ Complete Implementation
use xstate_statemachine::prelude::*;
const WIZARD_MACHINE: &str = r#"{
"id": "signup_wizard",
"initial": "step1_profile",
"context": {
"full_name": "",
"email": "",
"newsletter": false,
"plan": "starter"
},
"states": {
"step1_profile": {
"on": {
"NEXT": {
"target": "step2_preferences",
"guard": "isProfileComplete"
}
}
},
"step2_preferences": {
"on": {
"BACK": "step1_profile",
"NEXT": "step3_review"
}
},
"step3_review": {
"on": {
"BACK": "step2_preferences",
"SUBMIT": "completed"
}
},
"completed": {
"type": "final"
}
}
}"#;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let machine = Machine::from_json(WIZARD_MACHINE)?;
let logic = MachineLogic::dynamic()
.guard("isProfileComplete", |ctx, _event, _params| {
let name = ctx["full_name"].as_str().unwrap_or("");
let email = ctx["email"].as_str().unwrap_or("");
Ok(!name.is_empty() && email.contains('@'))
});
let mut interp = SyncInterpreter::new(machine, logic);
interp.start()?;
// Attempting NEXT with empty profile is rejected by guard
let receipt = interp.send("NEXT")?;
assert!(receipt.unhandled || receipt.state_ids() == ["signup_wizard.step1_profile"]);
// Populate context and proceed
interp.context_mut()["full_name"] = "Alice Smith".into();
interp.context_mut()["email"] = "alice@example.com".into();
let receipt = interp.send("NEXT")?;
assert_eq!(receipt.state_ids(), ["signup_wizard.step2_preferences"]);
Ok(())
}