logoRipple Effect
Examples

Multi-step Form Wizard

Build wizards and multi-step forms with event-driven navigation and validation

Multi-step forms and wizards are common in modern web applications, but managing their state and navigation can be complex. Using an event bus, each step can operate independently, triggering navigation events that other components listen to. This creates a clean, maintainable architecture.

The Problem

Without an event bus, multi-step forms require complex state management and prop drilling:

// ❌ Complex state management and prop drilling
function WizardContainer() {
  const [currentStep, setCurrentStep] = useState(1);
  const [formData, setFormData] = useState({});
  const [validationErrors, setValidationErrors] = useState({});

  return (
    <div>
      <Step1
        data={formData.step1}
        onChange={(data) => setFormData({ ...formData, step1: data })}
        onNext={() => {
          if (validateStep1(formData.step1)) {
            setCurrentStep(2);
          }
        }}
        errors={validationErrors.step1}
      />
      {currentStep >= 2 && (
        <Step2
          data={formData.step2}
          onChange={(data) => setFormData({ ...formData, step2: data })}
          onBack={() => setCurrentStep(1)}
          onNext={() => {
            if (validateStep2(formData.step2)) {
              setCurrentStep(3);
            }
          }}
          errors={validationErrors.step2}
        />
      )}
      {/* More steps... */}
    </div>
  );
}

This approach has several downsides:

  • Prop drilling: Each step needs access to navigation functions and state
  • Tight coupling: Steps are tightly coupled to the parent container
  • Complex state: All form data and validation state lives in one place
  • Hard to test: Steps can't be tested independently
  • Difficult to extend: Adding or removing steps requires modifying multiple components

The Solution

With an event bus, each step triggers events for navigation and validation. Separate components handle progress tracking and navigation:

"use client";

import {
  EventDriver,
  EventProvider,
  useMonitorEvent,
  useTriggerEvent,
} from "@protoworx/react-ripple-effect";

type FormEvents = {
  "step:next": { step: number };
  "step:back": { step: number };
  "step:validate": { step: number; isValid: boolean; data: any };
  "step:change": { step: number };
};

const client = new EventDriver();

export default function App() {
  return (
    <EventProvider client={client}>
      <WizardContainer />
      <ProgressTracker />
      <NavigationControls />
    </EventProvider>
  );
}

// Step components trigger events - no props needed!
function Step1() {
  const trigger = useTriggerEvent<FormEvents>();
  const [data, setData] = useState({ name: "", email: "" });

  const handleNext = () => {
    const isValid = data.name && data.email.includes("@");
    trigger("step:validate", { step: 1, isValid, data });
    if (isValid) {
      trigger("step:change", { step: 2 });
    }
  };

  return (
    <div>
      <input value={data.name} onChange={(e) => setData({ ...data, name: e.target.value })} />
      <input value={data.email} onChange={(e) => setData({ ...data, email: e.target.value })} />
      <button onClick={handleNext}>Next</button>
    </div>
  );
}

// Progress tracker listens to step changes
function ProgressTracker() {
  const [currentStep, setCurrentStep] = useState(1);

  useMonitorEvent<FormEvents>({
    "step:change": (data) => {
      setCurrentStep(data.step);
    },
  });

  return <div>Step {currentStep} of 4</div>;
}

Benefits

1. Separation of Concerns

Each step component focuses only on its own form fields and validation. Navigation and progress tracking are handled by separate components.

2. No Prop Drilling

Steps don't need to receive navigation functions or form data as props. They simply trigger events when ready to proceed.

3. Easy to Add Steps

Add a new step by creating a new component and registering it. No need to modify existing steps or the container:

function Step5() {
  const trigger = useTriggerEvent<FormEvents>();
  
  const handleNext = () => {
    trigger("step:validate", { step: 5, isValid: true, data: {} });
    trigger("step:change", { step: 6 });
  };
  
  return <div>Step 5 Content</div>;
}

4. Flexible Navigation

Navigation can be triggered from anywhere - buttons, keyboard shortcuts, or even external components:

function QuickNav() {
  const trigger = useTriggerEvent<FormEvents>();
  
  return (
    <div>
      <button onClick={() => trigger("step:change", { step: 1 })}>Go to Step 1</button>
      <button onClick={() => trigger("step:change", { step: 4 })}>Skip to Review</button>
    </div>
  );
}

5. Independent Testing

Test each step component independently without needing the full wizard:

test("Step1 validates email", () => {
  const { result } = renderHook(() => useTriggerEvent<FormEvents>());
  const trigger = result.current;
  
  // Simulate form submission
  trigger("step:validate", { step: 1, isValid: false, data: { email: "invalid" } });
  
  // Verify validation event was triggered
  expect(mockEventDriver.trigger).toHaveBeenCalledWith(
    "step:validate",
    expect.objectContaining({ isValid: false })
  );
});

6. Centralized Validation

Handle validation logic in one place, or distribute it across step components:

function ValidationMonitor() {
  useMonitorEvent<FormEvents>({
    "step:validate": (data) => {
      if (!data.isValid) {
        // Show error message
        showError(`Step ${data.step} validation failed`);
      } else {
        // Save step data
        saveStepData(data.step, data.data);
      }
    },
  });
  
  return null;
}

Complete Example

Here's a complete working example with visual feedback:

1
Personal Info
2
Shipping
3
Payment
4
Review

Personal Information

Step 1 of 4

Setup and Types

"use client";

import { cn } from "@/lib/utils";
import {
  EventDriver,
  EventProvider,
  useMonitorEvent,
  useTriggerEvent,
} from "@protoworx/react-ripple-effect";
import { useState } from "react";

const client = new EventDriver();

type FormEvents = {
  "step:next": { step: number };
  "step:back": { step: number };
  "step:validate": { step: number; isValid: boolean; data: any };
  "step:change": { step: number };
  "form:submit": { formData: any };
};

interface FormData {
  personalInfo: { name: string; email: string };
  shipping: { address: string; city: string; zipCode: string };
  payment: { cardNumber: string; expiryDate: string };
}

const STEPS = [
  { id: 1, title: "Personal Info" },
  { id: 2, title: "Shipping" },
  { id: 3, title: "Payment" },
  { id: 4, title: "Review" },
];

Main App Component

export default function App() {
  return (
    <EventProvider client={client}>
      <div className="p-4 border rounded-lg">
        <WizardContainer />
      </div>
    </EventProvider>
  );
}

Wizard Container

function WizardContainer() {
  const [currentStep, setCurrentStep] = useState(1);
  const [formData, setFormData] = useState<FormData>({
    personalInfo: { name: "", email: "" },
    shipping: { address: "", city: "", zipCode: "" },
    payment: { cardNumber: "", expiryDate: "" },
  });
  const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());

  useMonitorEvent<FormEvents>({
    "step:change": (data: FormEvents["step:change"]) => {
      setCurrentStep(data.step);
    },
    "step:validate": (data: FormEvents["step:validate"]) => {
      if (data.isValid) {
        setCompletedSteps((prev) => new Set([...prev, data.step]));
        setFormData((prev) => ({ ...prev, ...data.data }));
      }
    },
  });

  return (
    <div className="flex flex-col gap-6">
      <ProgressIndicator
        currentStep={currentStep}
        completedSteps={completedSteps}
      />
      <StepContent step={currentStep} formData={formData} />
      <NavigationButtons currentStep={currentStep} />
    </div>
  );
}

Progress Indicator

function ProgressIndicator({
  currentStep,
  completedSteps,
}: {
  currentStep: number;
  completedSteps: Set<number>;
}) {
  return (
    <div className="flex items-center justify-between">
      {STEPS.map((step, index) => (
        <div key={step.id} className="flex items-center flex-1">
          <div className="flex flex-col items-center flex-1">
            <div
              className={cn(
                "w-10 h-10 rounded-full flex items-center justify-center",
                completedSteps.has(step.id)
                  ? "bg-green-500 text-white"
                  : currentStep === step.id
                  ? "bg-blue-600 text-white"
                  : "bg-gray-200 text-gray-600"
              )}
            >
              {completedSteps.has(step.id) ? "✓" : step.id}
            </div>
            <div className="text-xs mt-2">{step.title}</div>
          </div>
          {index < STEPS.length - 1 && (
            <div
              className={cn(
                "h-1 flex-1 mx-2",
                completedSteps.has(step.id) ? "bg-green-500" : "bg-gray-200"
              )}
            />
          )}
        </div>
      ))}
    </div>
  );
}

Step Components

function StepContent({
  step,
  formData,
}: {
  step: number;
  formData: FormData;
}) {
  switch (step) {
    case 1:
      return <PersonalInfoStep />;
    case 2:
      return <ShippingStep />;
    case 3:
      return <PaymentStep />;
    case 4:
      return <ReviewStep formData={formData} />;
    default:
      return null;
  }
}

function PersonalInfoStep() {
  const trigger = useTriggerEvent<FormEvents>();
  const [data, setData] = useState({ name: "", email: "" });

  const handleNext = () => {
    const isValid = data.name.trim() !== "" && data.email.includes("@");
    trigger("step:validate", {
      step: 1,
      isValid,
      data: { personalInfo: data },
    });
    if (isValid) trigger("step:change", { step: 2 });
  };

  return (
    <div className="p-6 border rounded-lg">
      <h3 className="text-lg font-semibold mb-4">Personal Information</h3>
      <div className="space-y-4">
        <input
          type="text"
          value={data.name}
          onChange={(e) => setData({ ...data, name: e.target.value })}
          placeholder="Name"
          className="w-full px-3 py-2 border rounded-md"
        />
        <input
          type="email"
          value={data.email}
          onChange={(e) => setData({ ...data, email: e.target.value })}
          placeholder="Email"
          className="w-full px-3 py-2 border rounded-md"
        />
        <button
          onClick={handleNext}
          className="w-full px-4 py-2 bg-blue-600 text-white rounded"
        >
          Next
        </button>
      </div>
    </div>
  );
}

function ShippingStep() {
  const trigger = useTriggerEvent<FormEvents>();
  const [data, setData] = useState({ address: "", city: "", zipCode: "" });

  const handleNext = () => {
    const isValid =
      data.address.trim() !== "" &&
      data.city.trim() !== "" &&
      data.zipCode.trim() !== "";
    trigger("step:validate", {
      step: 2,
      isValid,
      data: { shipping: data },
    });
    if (isValid) trigger("step:change", { step: 3 });
  };

  return (
    <div className="p-6 border rounded-lg">
      <h3 className="text-lg font-semibold mb-4">Shipping Address</h3>
      <div className="space-y-4">
        <input
          type="text"
          value={data.address}
          onChange={(e) => setData({ ...data, address: e.target.value })}
          placeholder="Address"
          className="w-full px-3 py-2 border rounded-md"
        />
        <div className="grid grid-cols-2 gap-4">
          <input
            type="text"
            value={data.city}
            onChange={(e) => setData({ ...data, city: e.target.value })}
            placeholder="City"
            className="w-full px-3 py-2 border rounded-md"
          />
          <input
            type="text"
            value={data.zipCode}
            onChange={(e) => setData({ ...data, zipCode: e.target.value })}
            placeholder="Zip Code"
            className="w-full px-3 py-2 border rounded-md"
          />
        </div>
        <div className="flex gap-2">
          <button
            onClick={() => trigger("step:change", { step: 1 })}
            className="flex-1 px-4 py-2 border rounded"
          >
            Back
          </button>
          <button
            onClick={handleNext}
            className="flex-1 px-4 py-2 bg-blue-600 text-white rounded"
          >
            Next
          </button>
        </div>
      </div>
    </div>
  );
}

function PaymentStep() {
  const trigger = useTriggerEvent<FormEvents>();
  const [data, setData] = useState({ cardNumber: "", expiryDate: "" });

  const handleNext = () => {
    const isValid =
      data.cardNumber.replace(/\s/g, "").length === 16 &&
      data.expiryDate.length === 5;
    trigger("step:validate", {
      step: 3,
      isValid,
      data: { payment: data },
    });
    if (isValid) trigger("step:change", { step: 4 });
  };

  return (
    <div className="p-6 border rounded-lg">
      <h3 className="text-lg font-semibold mb-4">Payment Information</h3>
      <div className="space-y-4">
        <input
          type="text"
          value={data.cardNumber}
          onChange={(e) =>
            setData({ ...data, cardNumber: e.target.value })
          }
          placeholder="Card Number"
          className="w-full px-3 py-2 border rounded-md"
        />
        <input
          type="text"
          value={data.expiryDate}
          onChange={(e) =>
            setData({ ...data, expiryDate: e.target.value })
          }
          placeholder="MM/YY"
          className="w-full px-3 py-2 border rounded-md"
        />
        <div className="flex gap-2">
          <button
            onClick={() => trigger("step:change", { step: 2 })}
            className="flex-1 px-4 py-2 border rounded"
          >
            Back
          </button>
          <button
            onClick={handleNext}
            className="flex-1 px-4 py-2 bg-blue-600 text-white rounded"
          >
            Next
          </button>
        </div>
      </div>
    </div>
  );
}

function ReviewStep({ formData }: { formData: FormData }) {
  const trigger = useTriggerEvent<FormEvents>();

  const handleSubmit = () => {
    trigger("form:submit", { formData });
    alert("Form submitted successfully! 🎉");
  };

  return (
    <div className="p-6 border rounded-lg">
      <h3 className="text-lg font-semibold mb-4">Review Your Information</h3>
      <div className="space-y-4 mb-6">
        <div className="p-4 bg-muted rounded">
          <h4 className="font-semibold mb-2">Personal Information</h4>
          <p className="text-sm text-muted-foreground">Name: {formData.personalInfo.name}</p>
          <p className="text-sm text-muted-foreground">Email: {formData.personalInfo.email}</p>
        </div>
        <div className="p-4 bg-muted rounded">
          <h4 className="font-semibold mb-2">Shipping Address</h4>
          <p className="text-sm text-muted-foreground">{formData.shipping.address}</p>
          <p className="text-sm text-muted-foreground">
            {formData.shipping.city}, {formData.shipping.zipCode}
          </p>
        </div>
        <div className="p-4 bg-muted rounded">
          <h4 className="font-semibold mb-2">Payment</h4>
          <p className="text-sm text-muted-foreground">Card: •••• {formData.payment.cardNumber.slice(-4)}</p>
          <p className="text-sm text-muted-foreground">Expiry: {formData.payment.expiryDate}</p>
        </div>
      </div>
      <div className="flex gap-2">
        <button
          onClick={() => trigger("step:change", { step: 3 })}
          className="flex-1 px-4 py-2 border rounded"
        >
          Back
        </button>
        <button
          onClick={handleSubmit}
          className="flex-1 px-4 py-2 bg-green-600 text-white rounded"
        >
          Submit
        </button>
      </div>
    </div>
  );
}

function NavigationButtons({ currentStep }: { currentStep: number }) {
  const trigger = useTriggerEvent<FormEvents>();

  return (
    <div className="flex justify-between text-sm">
      <button
        onClick={() => {
          if (currentStep > 1) {
            trigger("step:change", { step: currentStep - 1 });
          }
        }}
        disabled={currentStep === 1}
        className={cn(
          currentStep === 1 ? "text-gray-400" : "text-blue-600"
        )}
      >
        ← Previous Step
      </button>
      <div>Step {currentStep} of {STEPS.length}</div>
    </div>
  );
}

Key Takeaways

  • Event-driven navigation: Steps trigger step:change events instead of calling parent functions
  • Independent components: Each step is a self-contained component that doesn't need navigation props
  • Progress tracking: Separate component listens to step changes to update progress indicator
  • Validation: Steps trigger step:validate events before navigation, allowing centralized or distributed validation
  • Easy to extend: Add new steps by creating new components and registering them in the step router
  • Clean architecture: No prop drilling or complex state management needed

This pattern works great for checkout flows, onboarding wizards, multi-step surveys, or any sequential form where steps need to communicate but remain independent.