Wizard Forms
A wizard is useful when one task is completed in several passes: signup, onboarding, checkout, integration setup, or a multi-page questionnaire. The hard part is not the Back and Next buttons. The hard part is the model: where the final payload lives, when a step is validated, how conditional steps are skipped, and how existing forms are reused.
In @virentia/forms, a wizard step is a form.
step = form + navigation metadataThe wizard does not own fields. It owns navigation between forms.
Shared root form
The most common case is that all steps edit one final object. For example, signup has account data, plan choice, and billing details.
const signup = createForm({
schema: {
email: createField("", {
validate: zodFieldValidator(z.string().email("Invalid email")),
}),
password: createField("", {
validate: zodFieldValidator(
z.string().min(8, "Use at least 8 characters"),
),
}),
plan: createField<"free" | "team">("free"),
billingEmail: createField(""),
},
});Steps are created as projections of this form:
const wizard = createWizard({
form: signup,
steps: [
step("account", {
title: "Account",
form: signup.pick({
email: true,
password: true,
}),
}),
step("plan", {
title: "Plan",
form: signup.pick({
plan: true,
}),
}),
step("billing", {
title: "Billing",
form: signup.pick({
billingEmail: true,
}),
when: ({ values }) => values.plan === "team",
}),
],
});What happens:
signupremains the single source of final values;pickdoes not copy fields, it creates a form projection over the same field instances;- the
accountstep validates onlyemailandpassword; - the
billingstep is visible only for the team plan; wizard.read()returnssignup.read().
Moving to the next step
const moved = await wizard.next();Algorithm:
current = visibleSteps[currentIndex]
await current.form.validate()
if current.form.isValid:
mark current step completed
move to next visible step
return true
return falseThe user cannot move forward while the current step form is invalid. Errors stay inside that step form, so the UI displays them the same way as in a regular form.
Jump navigation
goTo(id) is useful for sidebar navigation.
await wizard.goTo("billing");If the target is behind the current step, the wizard moves without validation. If the target is ahead, the wizard validates every intermediate visible step and stops at the first invalid one.
This keeps completed steps freely reachable without allowing users to skip required checks.
Completing the wizard
const completed = await wizard.complete();complete() validates all visible steps. If one step is invalid, the wizard moves to it and returns false. If all steps are valid, it emits completed and returns true.
if (await wizard.complete()) {
await saveSignup(signup.read());
}Standalone step forms
A root form is not always needed. Sometimes each step is already a feature model: contact import settings, webhook configuration, and connection testing.
const importSettings = createForm({
schema: {
source: createField<"csv" | "crm">("csv"),
},
});
const webhookSettings = createForm({
schema: {
url: createField("", {
validate: zodFieldValidator(z.string().url("Invalid URL")),
}),
},
});
const wizard = createWizard({
steps: [
step("import", { form: importSettings }),
step("webhook", { form: webhookSettings }),
],
});Without a root form, wizard.read() returns an object by step id:
{
import: importSettings.read(),
webhook: webhookSettings.read(),
}Use this mode when steps are genuinely independent and should not share one schema.
createWizardForm
If you always create a root form and immediately describe steps, use the helper.
const wizard = createWizardForm({
schema: {
email: createField(""),
password: createField(""),
displayName: createField(""),
},
steps(form) {
return [
step("account", {
form: form.pick({
email: true,
password: true,
}),
}),
step("profile", {
form: form.pick({
displayName: true,
}),
}),
];
},
});
wizard.form; // root formThe helper does not introduce a new behavior model. It only shortens the shared root form pattern.
React UI
A wizard can be read through useWizard.
function WizardControls() {
const wizard = useWizard(signupWizard);
return (
<footer>
<button disabled={!wizard.canGoBack} onClick={() => void wizard.back()}>
Back
</button>
<button disabled={!wizard.canGoNext} onClick={() => void wizard.next()}>
Next
</button>
</footer>
);
}The current step stores a form, so a screen can choose the component by currentId and pass currentForm to it.
Contract
interface WizardStep<Id extends string, StepForm extends AnyForm> {
readonly id: Id;
readonly form: StepForm;
readonly title?: string;
readonly when?: (ctx: { values: unknown }) => boolean;
}
interface Wizard<Steps, RootForm> {
readonly form: RootForm;
readonly steps: Store<Steps>;
readonly visibleSteps: Store<Steps>;
readonly currentId: Store<StepId>;
readonly currentIndex: Store<number>;
readonly currentStep: Store<Step>;
readonly currentForm: Store<Form>;
readonly visitedIds: Store<readonly StepId[]>;
readonly completedIds: Store<readonly StepId[]>;
readonly canGoBack: Store<boolean>;
readonly canGoNext: Store<boolean>;
next(): Promise<boolean>;
back(): Promise<boolean>;
goTo(id: StepId): Promise<boolean>;
complete(): Promise<boolean>;
reset(): Promise<void>;
read(): unknown;
}Next
- Validation - what happens inside
step.form.validate(). - React - how to connect a wizard to the UI.
- Form model - why a step can be any form projection.
- API reference -
Wizard,WizardStep, andcreateWizardForm.