Bỏ qua để vào nội dung chính
Building a Multi-Step Form with React Hook Form and Server Actions

Building a Multi-Step Form with React Hook Form and Server Actions

Bởi Amelia Brooks
26 thg 3, 20268 phút đọc

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.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan