Multi-Step Wizard
Problem
A long form is split into pages that users move through sequentially. Each page is validated before advancing, the user can go back to edit earlier pages, and the final step submits the complete dataset.
Solution
Use scope() to give each step its own relative field namespace. Each step's scope.validate() returns only that step's errors so advancing is safe even when sibling steps have pre-existing errors.
ts
import { createForm } from '@vielzeug/forge';
const form = createForm({
defaultValues: {
personal: { firstName: '', lastName: '', email: '' },
address: { street: '', city: '', zipCode: '' },
payment: { cardNumber: '', expiryDate: '', cvv: '' },
},
validators: {
'personal.firstName': (v) => (!v ? 'First name is required' : undefined),
'personal.lastName': (v) => (!v ? 'Last name is required' : undefined),
'personal.email': (v) => (!v ? 'Email is required' : !String(v).includes('@') ? 'Invalid email' : undefined),
'address.street': (v) => (!v ? 'Street is required' : undefined),
'address.city': (v) => (!v ? 'City is required' : undefined),
'address.zipCode': (v) => (!v ? 'ZIP code is required' : !/^\d{5}$/.test(String(v)) ? 'Invalid ZIP' : undefined),
'payment.cardNumber': (v) =>
!v
? 'Card number is required'
: !/^\d{16}$/.test(String(v).replace(/\s/g, ''))
? 'Invalid card number'
: undefined,
'payment.expiryDate': (v) => (!v ? 'Expiry date is required' : undefined),
'payment.cvv': (v) => (!v ? 'CVV is required' : !/^\d{3,4}$/.test(String(v)) ? 'Invalid CVV' : undefined),
},
});
// scope() is memoized — repeated calls with the same prefix return the same object
const steps = [
{ title: 'Personal Info', scope: form.scope('personal') },
{ title: 'Address', scope: form.scope('address') },
{ title: 'Payment', scope: form.scope('payment') },
];
let currentStep = 0;
async function nextStep() {
// validate() on the scoped form validates only this step's fields and
// returns relative-key errors — sibling errors do not bleed in
const { valid } = await steps[currentStep].scope.validate();
if (valid && currentStep < steps.length - 1) {
currentStep++;
updateStepUI();
}
}
async function submitWizard() {
// submit() on the parent form validates everything and sends all values
const result = await form.submit(async (values) => {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
return response.json();
});
if (!result.ok) return;
window.location.href = '/confirmation';
}
function updateStepUI() {
console.log(`Step ${currentStep + 1}/${steps.length}: ${steps[currentStep].title}`);
}Pitfalls
scope()is memoized — repeated calls with the same prefix return the same cached object. However, calling it during a render or inside a tight loop is still wasteful; store the result for clarity.scope.statereflects the full form —state.isValidisfalseif any step has an error. Usescope.validate()for per-step validity, notstate.isValid.- Navigating backward does not re-run validation. If you want to re-surface errors on return, call
scope.validate()when the user navigates to a step. form.submit()validates all fields across all steps — this is correct for final submission. Usescope.validate()for per-step validation when advancing.