Tier 1 · Must-have★ FeaturedRecipe #5

Multi-Step Wizard

Per-step validation, sessionStorage-backed progress, and safe cleanup on final submit — the most complete demonstration of what makes this library different.

See live demo →

Why it matters

Multi-step wizards are notoriously painful to implement: you need to validate only the current step's fields before advancing, persist progress so the user can close the tab and come back, and cleanly submit all steps' data in a single action at the end.

hookform-action solves this with a single hook instance spanning all steps.trigger(fields) validates a subset of fields without submitting. persistKey automatically serialises form state to sessionStorage on every change. And clearPersistedData() removes the draft cleanly on success. No external state manager needed.

Full Example

actions.ts
'use server'
import { z } from 'zod'
import { withZod } from 'hookform-action-core/with-zod'

// Single schema covering all wizard steps
const onboardingSchema = z.object({
  // Step 1: Personal Info
  firstName: z.string().min(2, 'First name must be at least 2 characters'),
  lastName:  z.string().min(2, 'Last name must be at least 2 characters'),
  email:     z.string().email('Enter a valid email address'),
  // Step 2: Company
  company: z.string().min(1, 'Company name is required'),
  role:    z.string().min(1, 'Role is required'),
  // Step 3: Plan
  plan: z.enum(['free', 'pro', 'enterprise'], {
    errorMap: () => ({ message: 'Please select a plan' }),
  }),
})

export const onboardingAction = withZod(onboardingSchema, async (data) => {
  // All fields are available and typed here
  await createAccount(data)
  return { success: true }
})
wizard-form.tsx
'use client'
import { useActionForm } from 'hookform-action'
import { useState } from 'react'
import { onboardingAction } from './actions'

// Map each step index to the fields it owns
const STEP_FIELDS = {
  0: ['firstName', 'lastName', 'email'],
  1: ['company', 'role'],
  2: ['plan'],
} as const

export function OnboardingWizard() {
  const [step, setStep] = useState(0)

  const {
    register,
    handleSubmit,
    trigger,
    clearPersistedData,
    formState: { errors, isPending, isSubmitSuccessful },
  } = useActionForm(onboardingAction, {
    defaultValues: {
      firstName: '', lastName: '', email: '',
      company: '', role: '',
      plan: '',
    },
    // Automatically save progress to sessionStorage
    persistKey: 'onboarding-wizard',
    persistDebounce: 250,
    onSuccess: () => {
      // Remove the draft once the wizard is complete
      clearPersistedData()
    },
  })

  // Validate only the current step's fields before advancing
  const handleNext = async () => {
    const fields = STEP_FIELDS[step as 0 | 1 | 2]
    const valid = await trigger(fields as never[])
    if (valid) setStep((s) => s + 1)
  }

  if (isSubmitSuccessful) {
    return (
      <div className="text-center py-16">
        <p className="text-2xl font-bold text-green-400">🎉 Welcome aboard!</p>
        <p className="text-gray-400 mt-2">Your account is ready.</p>
      </div>
    )
  }

  return (
    <form onSubmit={handleSubmit()}>
      {/* Progress indicator */}
      <div className="flex gap-2 mb-8">
        {['Personal', 'Company', 'Plan'].map((label, i) => (
          <div key={label} className="flex items-center gap-2 flex-1">
            <div
              className={
                i <= step
                  ? 'w-6 h-6 rounded-full bg-brand-600 text-white text-xs flex items-center justify-center'
                  : 'w-6 h-6 rounded-full bg-gray-800 text-gray-500 text-xs flex items-center justify-center'
              }
            >
              {i + 1}
            </div>
            <span className={i <= step ? 'text-sm text-gray-200' : 'text-sm text-gray-600'}>
              {label}
            </span>
          </div>
        ))}
      </div>

      {/* Step 1 */}
      {step === 0 && (
        <div className="space-y-4">
          <div>
            <label>First Name</label>
            <input {...register('firstName')} />
            {errors.firstName && <p className="text-red-400">{errors.firstName.message}</p>}
          </div>
          <div>
            <label>Last Name</label>
            <input {...register('lastName')} />
            {errors.lastName && <p className="text-red-400">{errors.lastName.message}</p>}
          </div>
          <div>
            <label>Email</label>
            <input type="email" {...register('email')} />
            {errors.email && <p className="text-red-400">{errors.email.message}</p>}
          </div>
        </div>
      )}

      {/* Step 2 */}
      {step === 1 && (
        <div className="space-y-4">
          <div>
            <label>Company</label>
            <input {...register('company')} />
            {errors.company && <p className="text-red-400">{errors.company.message}</p>}
          </div>
          <div>
            <label>Role</label>
            <input {...register('role')} />
            {errors.role && <p className="text-red-400">{errors.role.message}</p>}
          </div>
        </div>
      )}

      {/* Step 3 */}
      {step === 2 && (
        <div className="space-y-3">
          <label>Choose a plan</label>
          {(['free', 'pro', 'enterprise'] as const).map((p) => (
            <label key={p} className="flex items-center gap-2">
              <input type="radio" value={p} {...register('plan')} />
              {p.charAt(0).toUpperCase() + p.slice(1)}
            </label>
          ))}
          {errors.plan && <p className="text-red-400">{errors.plan.message}</p>}
        </div>
      )}

      {/* Navigation */}
      <div className="flex gap-3 mt-8">
        {step > 0 && (
          <button type="button" onClick={() => setStep((s) => s - 1)}>
            Back
          </button>
        )}
        {step < 2 ? (
          <button type="button" onClick={handleNext}>
            Next
          </button>
        ) : (
          <button type="submit" disabled={isPending}>
            {isPending ? 'Creating account…' : 'Complete Setup'}
          </button>
        )}
      </div>
    </form>
  )
}

Key Concepts

trigger(fields)

Runs client-side validation on a specific subset of fields and returns true if all pass. Pass only the fields of the current step to avoid surfacing errors from future steps prematurely. Calling trigger() with no arguments validates everything — avoid this between steps.

persistKey + persistDebounce

persistKey enables automatic sessionStorage persistence. The form state is saved under this key on every change (debounced by persistDebounce ms, default 300) and restored on mount. The user can close the browser and return to find their progress intact.

clearPersistedData()

Manually removes the persisted state for this form's persistKey. Always call it in onSuccess after wizard completion — otherwise the next user to open the form (or the current user if they start again) will see the completed wizard's data pre-filled.

Single hook instance across all steps

All fields are registered in one useActionForm call. The active step is controlled by plain useState. This means the entire form state is consistent at all times, and the final submit sends all fields in one action call.

⚠️ Pitfalls

  • Calling trigger() without arguments between steps

    This validates all fields — including ones in future steps the user has not seen yet — and shows errors prematurely. Always pass the explicit list of fields for the current step.

  • Using isSubmitSuccessful to advance between steps

    isSubmitSuccessful only becomes true after the final action call succeeds. Use it exclusively as the guard for the completion screen. Step navigation is purely local state.

  • Forgetting clearPersistedData() after submission

    The persistence happens automatically on every change. After success, the persisted data is not removed automatically — you must call clearPersistedData() in onSuccess, or the next visit to the form will restore the completed wizard.

  • The current step is not persisted by default

    persistKey saves field values, not the step index. If the user returns after a refresh, the form starts at step 0 with the field values intact. To also persist the step, store it in sessionStorage alongside the form, or include a hidden currentStep field.

Related