· Michał Roman · Design patterns · 6 min read
Building a Wizard Builder for Multi-Step Forms
Building a Wizard Builder for Multi-Step Forms to simplify the process, enhance the developer experience and user experience at the same time.
Building a Wizard Builder for Multi-Step Forms
Multi-step forms are common in e-commerce. Checkout flows, product creation, user registration. They all follow similar patterns but often get built differently.
This can create issues. Inconsistent user experience. Repeated validation logic. Complex state management. Hard to maintain.
Here’s an approach to building a wizard builder that addresses these concerns.
Common Challenges
E-commerce platforms typically need various multi-step forms:
- Checkout: Cart → Shipping → Payment → Confirmation
- Product creation: Basic info → Pricing → Categories → Review
- Vendor onboarding: Company details → Banking → Verification
- Customer registration: Personal info → Preferences → Email verification
When different teams build these separately, problems can emerge.
Typical Issues
Inconsistent experience. Each form might feel different. Users need to learn new patterns for each workflow.
Scattered validation. Business rules get spread across components. Changes become more complex.
State management complexity. Managing form data across multiple steps often requires significant boilerplate code.
Maintenance challenges. Adding a step might mean touching multiple files. Changes become riskier.
A Configuration-Based Approach
A wizard builder can treat multi-step forms as configuration instead of implementation details.
You define what you want. The system handles how it works.
Core Principles
Configuration over code. Describe your wizard in a simple object. Don’t implement the navigation logic yourself.
Centralized state. All wizard state lives in one place. No need to pass props between distant components.
Reusable components. Build a step once. Use it in different wizards.
Schema validation. Write business rules as schemas. Get type safety automatically.
Architecture Overview
The wizard builder uses several established design patterns that work together to create a flexible and maintainable system.
Design Patterns in Use
Provider Pattern. React Context provides centralized state management without prop drilling. All wizard state lives in one place and components access it through the useWizard hook.
Configuration Pattern. Instead of writing imperative navigation code, you describe what you want declaratively. The wizard interprets this configuration and handles the implementation details.
Component Composition. Individual steps are self-contained components that can be mixed and matched across different wizards. Each step focuses on one responsibility.
Schema-First Validation. Business rules are expressed as Zod schemas that provide both runtime validation and compile-time type safety. This separates validation logic from UI concerns.
Compound Component Pattern. Multiple related components work together to create the wizard experience. WizardBuilder, WizardStepRenderer, WizardNavigation, and WizardProgressBar each handle specific aspects.
Render Props Pattern. Steps are defined as React components that get injected into the wizard framework. The wizard handles when and how to render them.
1. Configuration First
Every wizard starts with a config object:
const productWizard = {
title: "Create Product",
steps: [
{
id: 'info',
title: 'Info',
component: InfoStep,
schema: productInfoSchema,
},
{
id: 'pricing',
title: 'Pricing',
component: PricingStep,
schema: pricingSchema,
},
{
id: 'review',
title: 'Review',
component: ReviewStep,
},
],
onComplete: (data) => createProduct(data)
};
This describes what you want without navigation code or state management details.
2. Context for State Management
React Context manages the wizard state:
const wizard = useWizard();
// Navigate
wizard.goToNextStep();
wizard.goToPreviousStep();
// Check status
wizard.isFirstStep;
wizard.isLastStep;
// Validate
wizard.isStepValid(2);
The context handles step navigation, form validation before moving forward, integration with React Hook Form, and sub-forms for complex cases.
3. Schema-Based Validation
Zod schemas define business rules:
const shippingSchema = z.object({
fullName: z.string().min(1, "Name required"),
address: z.string().min(1, "Address required"),
city: z.string().min(1, "City required"),
zip: z.string().regex(/^\d{5}$/, "Invalid zip"),
});
This provides type safety automatically, allows reuse of common patterns, enables testing business rules separately, and makes validation changes easier.
4. Step Components
Each step is a standard React component:
function ShippingStep() {
const { control } = useFormContext();
const { addSubForm } = useWizard();
const addNewAddress = () => {
addSubForm('address', () => <AddressForm />);
};
return (
<div>
<FormSelect
control={control}
name="shippingAddress"
options={addresses}
/>
<button onClick={addNewAddress}>
Add New Address
</button>
</div>
);
}
Steps are isolated. They don’t need to know about navigation or other steps. Each focuses on its specific responsibility.
Example Implementation: Checkout Flow
Here’s how a checkout wizard might look:
const checkoutWizard = {
title: "Complete Order",
steps: [
{
id: 'cart',
title: 'Cart',
component: CartStep,
},
{
id: 'shipping',
title: 'Shipping',
component: ShippingStep,
schema: shippingSchema,
},
{
id: 'payment',
title: 'Payment',
component: PaymentStep,
schema: paymentSchema,
},
{
id: 'confirmation',
title: 'Confirmation',
component: ConfirmationStep,
},
],
initialData: {
items: cartItems,
user: currentUser,
},
onComplete: async (order) => {
await processOrder(order);
redirect('/thank-you');
}
};
The payment step handles different methods:
function PaymentStep() {
const { control, watch } = useFormContext();
const method = watch('paymentMethod');
return (
<div>
<FormRadio
control={control}
name="paymentMethod"
options={['card', 'paypal']}
/>
{method === 'card' && <CreditCardForm />}
{method === 'paypal' && <PayPalForm />}
</div>
);
}
Complex validation stays readable:
const paymentSchema = z.object({
paymentMethod: z.enum(['card', 'paypal']),
cardNumber: z.string().optional(),
expiry: z.string().optional(),
}).refine((data) => {
if (data.paymentMethod === 'card') {
return data.cardNumber && data.expiry;
}
return true;
}, "Card details required");
Benefits of This Approach
Development Speed
New wizards can be created quickly by combining existing step components with new configuration. The Provider Pattern eliminates state management boilerplate. The Configuration Pattern means most work becomes describing what you want rather than implementing how it works.
User Experience Consistency
All wizards work the same way because they use the same underlying Compound Component system. Same navigation patterns, progress indicators, and error handling approaches. The Component Composition pattern ensures consistent behavior across different workflows.
Maintenance Advantages
The Configuration Pattern means wizard behavior changes happen in one place. Schema-First Validation keeps business rules separate from UI code. Component Composition enables reuse so bug fixes apply everywhere at once.
Testing Benefits
The Provider Pattern allows step components to be tested in isolation by mocking the wizard context. Schema-First Validation means business logic can be tested separately from UI concerns. The Render Props pattern makes it easy to test wizard configurations without building real components.
Performance Considerations
Several optimizations can help with performance. Steps can load lazily when needed for smaller initial bundles. React Hook Form keeps re-renders minimal during user input. Validation results can be cached to avoid repeated computation. Complex features can load on demand to keep the base wizard lightweight.
Why This Matters
This architecture demonstrates how combining established design patterns creates something greater than the sum of its parts. The Provider Pattern handles state complexity. Configuration Pattern eliminates boilerplate. Component Composition enables reuse. Schema-First Validation ensures type safety and business rule clarity.
Each pattern addresses a specific concern, but together they create a system that treats forms as configuration rather than implementation details. Instead of building complex navigation and state management for each workflow, you focus on the unique business requirements.
The approach scales well because you’re building on proven patterns. Each new wizard requires less work. Business rules stay clear and testable. Users get predictable experiences.
The key insight is letting established patterns handle complexity while you focus on what makes your application unique. This creates a foundation that grows with your needs rather than becoming a burden.