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:
Personal Information
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:changeevents 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:validateevents 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.