· 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.

Back to Blog

Related Posts

View All Posts »

UI error handling in Next.js 19 with Error Boundaries

Handling errors properly in the UI is often one of the last things developers think about. But once your app grows, you realize how important it is to keep your interface from crashing and show something meaningful to users when things go wrong.

Next.js middleware use cases

If you're using Next.js and haven’t played with middleware yet, you're missing out on some really clever and efficient ways to handle logic between a request and your app’s response.

Advanced Tailwind tips and tricks

Unlock Tailwind CSS's potential with advanced tips, dynamic sizing, responsive layouts, and more to enhance your web development workflow