Building a Multi-Step Form with React Hook Form and Server Actions
A step-by-step guide to building a multi-step form wizard with React Hook Form, Zod validation, and Next.js Server Actions.
Multi-step forms are one of those features that sound simple but quickly become complex — managing state across steps, validating per step, handling back navigation without losing data, and submitting everything at the end. Here's how to build one properly with React Hook Form, Zod, and Server Actions.
The Architecture
We'll build a three-step onboarding form: personal info, preferences, and confirmation. Each step validates independently. The final step submits everything via a Server Action. Users can navigate back without losing progress.
Step 1: Define the Schema
Start with a Zod schema for each step, then merge them for the final submission:
// lib/validations/onboarding.ts
import { z } from 'zod';
export const personalInfoSchema = z.object({
name: z.string().min(2, 'Name is required'),
email: z.string().email('Invalid email'),
company: z.string().optional(),
});
export const preferencesSchema = z.object({
role: z.enum(['developer', 'designer', 'manager', 'other']),
experience: z.enum(['junior', 'mid', 'senior', 'lead']),
interests: z.array(z.string()).min(1, 'Select at least one interest'),
});
// Combined schema for final submission
export const onboardingSchema = personalInfoSchema.merge(preferencesSchema);
export type OnboardingData = z.infer<typeof onboardingSchema>;
Step 2: The Form Provider
Use React Hook Form with a shared form context that persists across steps:
"use client";
import { useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { onboardingSchema, type OnboardingData } from "@/lib/validations/onboarding";
import { completeOnboarding } from "@/actions/user.actions";
import { PersonalInfoStep } from "./steps/personal-info";
import { PreferencesStep } from "./steps/preferences";
import { ConfirmationStep } from "./steps/confirmation";
const STEPS = [
{ component: PersonalInfoStep, fields: ["name", "email", "company"] },
{ component: PreferencesStep, fields: ["role", "experience", "interests"] },
{ component: ConfirmationStep, fields: [] },
] as const;
export function OnboardingForm() {
const [step, setStep] = useState(0);
const [submitError, setSubmitError] = useState<string | null>(null);
const methods = useForm<OnboardingData>({
resolver: zodResolver(onboardingSchema),
mode: "onTouched",
defaultValues: {
name: "",
email: "",
company: "",
role: undefined,
experience: undefined,
interests: [],
},
});
const currentStep = STEPS[step];
const StepComponent = currentStep.component;
const isLastStep = step === STEPS.length - 1;
const handleNext = async () => {
const fields = STEPS[step].fields as (keyof OnboardingData)[];
const isValid = await methods.trigger(fields);
if (isValid) setStep((s) => s + 1);
};
const handleBack = () => setStep((s) => Math.max(0, s - 1));
const handleSubmit = methods.handleSubmit(async (data) => {
const result = await completeOnboarding(data);
if (result.error) setSubmitError(result.error);
});
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit}>
<div className="mb-8">
<div className="flex gap-2">
{STEPS.map((_, i) => (
<div
key={i}
className={`h-2 flex-1 rounded ${
i <= step ? "bg-primary" : "bg-muted"
}`}
/>
))}
</div>
<p className="text-sm text-muted-foreground mt-2">
Step {step + 1} of {STEPS.length}
</p>
</div>
<StepComponent />
{submitError && (
<p className="text-red-500 mt-4">{submitError}</p>
)}
<div className="flex justify-between mt-8">
<button
type="button"
onClick={handleBack}
disabled={step === 0}
className="px-4 py-2 border rounded disabled:opacity-50"
>
Back
</button>
{isLastStep ? (
<button
type="submit"
disabled={methods.formState.isSubmitting}
className="px-4 py-2 bg-primary text-white rounded"
>
{methods.formState.isSubmitting
? "Submitting..."
: "Complete"}
</button>
) : (
<button
type="button"
onClick={handleNext}
className="px-4 py-2 bg-primary text-white rounded"
>
Next
</button>
)}
</div>
</form>
</FormProvider>
);
}
Step 3: Individual Step Components
Each step component uses useFormContext to access the shared form state. It only renders its own fields:
"use client";
import { useFormContext } from "react-hook-form";
import type { OnboardingData } from "@/lib/validations/onboarding";
export function PersonalInfoStep() {
const { register, formState: { errors } } = useFormContext<OnboardingData>();
return (
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
Full Name
</label>
<input
id="name"
{...register("name")}
className="mt-1 block w-full rounded border px-3 py-2"
/>
{errors.name && (
<p className="text-red-500 text-sm mt-1">
{errors.name.message}
</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
{...register("email")}
className="mt-1 block w-full rounded border px-3 py-2"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">
{errors.email.message}
</p>
)}
</div>
</div>
);
}
Key Design Decisions
Per-step validation with trigger() — we only validate the current step's fields before advancing. This prevents showing errors for steps the user hasn't reached yet.
Shared form state via FormProvider — one form instance owns all the data. Navigating back preserves everything. No external state management needed.
Server Action for final submit — the complete dataset is validated again server-side with the merged Zod schema. Never trust client validation alone.
Progress indicator — simple visual feedback that the user is making progress. Reduces form abandonment significantly.
This pattern scales to any number of steps. Add a new schema, a new step component, and an entry in the STEPS array. The form provider handles the rest.
Admin
Cal.com
Open source scheduling — tự host booking system, thay thế Calendly. Free & privacy-first.
Bình luận (0)
Đăng nhập để bình luận
Chưa có bình luận nào. Hãy là người đầu tiên!