BuildWithMatija
Get In Touch
Build with Matija logo

Build with Matija

Modern websites, content systems, and AI workflows built for long-term growth.

Services

  • Headless CMS Websites
  • Next.js & Headless CMS Advisory
  • AI Systems & Automation
  • Website & Content Audit

Resources

  • Case Studies
  • How I Work
  • Blog
  • CMS Hub
  • E-commerce Hub
  • Dashboard

Headless CMS

  • Payload CMS Developer
  • CMS Migration
  • Payload vs Sanity
  • Payload vs WordPress
  • Payload vs Contentful

Get in Touch

Ready to modernize your stack? Let's talk about what you're building.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
  1. Home
  2. Build a Multi-Step TanStack Form Wizard in Next.js

Build a Multi-Step TanStack Form Wizard in Next.js

Production-ready Next.js pattern with TanStack Form and Zod — autosave drafts, conditional steps, and per-step…

24th May 2026·Updated on:29th May 2026··
Build a Multi-Step TanStack Form Wizard in Next.js

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

A practical follow-along guide extracted from a production onboarding wizard. By the end you will have a working, copy-pasteable pattern for a wizard that:

  • holds all step data in one TanStack Form instance
  • shows or hides steps based on earlier answers
📄View markdown version
0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

No comments yet

Be the first to share your thoughts on this post!

About the author

Author Profile: Matija Žiberna

Matija Žiberna
Matija Žiberna
Role: Full-stack developer, co-founder

Self-taught full-stack developer sharing lessons from building software and startups.

Table of Contents

  • What this guide covers
  • 1. Requirements and assumptions
  • Install dependencies
  • Assumptions
  • What `Field` / `FieldError` look like
  • 2. Folder structure
  • 3. Types — `wizard.types.ts`
  • 4. Step configuration — `wizard-step-config.ts`
  • 5. Validation — `wizard.validation.ts`
  • 6. Form hook — `useWizardForm.ts`
dynamically
  • validates one step at a time with Zod, mapping errors back into the form
  • autosaves progress to localStorage so a browser refresh does not lose work
  • submits everything in a single server action at the end
  • The example builds a subscription onboarding flow: plan → account → billing → team (conditional) → review. The team step only appears when the user picks a multi-user plan. Swap the domain details for your own use case.!

    Your browser does not support the video tag.

    What this guide covers

    1. Requirements and assumptions
    2. Folder structure
    3. Types — wizard.types.ts
    4. Step configuration — wizard-step-config.ts
    5. Validation — wizard.validation.ts
    6. Form hook — useWizardForm.ts
    7. Step navigation hook — useWizardStep.ts
    8. Step components — StepAccount, StepPlan, StepBilling, StepReview (all included)
    9. Draft persistence — wizard-persist.ts
    10. Shell component — WizardShell.tsx
    11. Server action — wizard.actions.ts
    12. Mapping server errors back into the form
    13. Advanced: array steps
    14. Advanced: credential sidecar
    15. Advanced: dev debug panel
    16. Adding a new step — checklist
    17. Common pitfalls

    1. Requirements and assumptions

    Install dependencies

    pnpm add @tanstack/react-form zod lucide-react
    # or npm / yarn equivalents
    

    Assumptions

    • Next.js App Router with Server Actions enabled ('use server' directive)
    • TypeScript with strict mode recommended
    • React client components for the wizard UI ('use client')
    • UI primitives (Field, FieldLabel, FieldError, Input) — examples use shadcn-style components, but they are thin wrappers. Replace them with any component library or inline HTML. The pattern does not depend on them.

    What Field / FieldError look like

    If you do not have these components, the minimal versions below are enough to follow along:

    // components/ui/field.tsx
    export function Field({ children, className }: React.PropsWithChildren<{ className?: string }>) {
      return <div className={`flex flex-col gap-1 ${className ?? ''}`}>{children}</div>
    }
    
    export function FieldLabel(props: React.LabelHTMLAttributes<HTMLLabelElement>) {
      return <label {...props} className={`text-sm font-medium ${props.className ?? ''}`} />
    }
    
    export function FieldError({ errors }: { errors?: Array<{ message: string } | string> }) {
      const messages = (errors ?? [])
        .map((e) => (typeof e === 'string' ? e : e.message))
        .filter(Boolean)
      if (messages.length === 0) return null
      return <p className="text-sm text-red-600">{messages[0]}</p>
    }
    

    2. Folder structure

    src/wizard/
    ├── wizard.types.ts             # Shared TypeScript types
    ├── wizard-step-config.ts       # Step list and visibility rules
    ├── wizard.validation.ts        # Zod schemas — one per step + full form
    ├── useWizardForm.ts            # TanStack Form factory and defaults
    ├── useWizardStep.ts            # Step navigation and per-step validation
    ├── wizard-persist.ts           # localStorage draft: save / load / clear
    ├── wizard.actions.ts           # Server action: submitWizard()
    ├── WizardShell.tsx             # Top-level client component
    └── steps/
        ├── StepPlan.tsx
        ├── StepAccount.tsx
        ├── StepBilling.tsx
        ├── StepTeam.tsx
        └── StepReview.tsx
    

    3. Types — wizard.types.ts

    Define the full form shape in one place. Every other file imports from here.

    // wizard.types.ts
    
    export type Plan = 'solo' | 'team' | 'enterprise'
    export type BillingInterval = 'monthly' | 'annual'
    
    export interface TeamMember {
      id: string      // client-side key for React rendering
      email: string
      role: 'admin' | 'member'
    }
    
    export interface WizardFormValues {
      // Step: plan
      plan: Plan
    
      // Step: account
      email: string
      password: string
      confirmPassword: string   // never saved to localStorage; stripped before the server call (see §11)
    
      // Step: billing
      billingInterval: BillingInterval
    
      // Step: team (conditional — only when plan !== 'solo')
      teamName: string
      teamSlug: string
      teamMembers: TeamMember[]
    }
    
    // Sent to the server — confirmPassword is stripped before the call.
    // The server never needs to see it: match validation is client-side only.
    export type WizardServerInput = Omit<WizardFormValues, 'confirmPassword'>
    

    4. Step configuration — wizard-step-config.ts

    A single file owns the step list and all visibility rules. getVisibleSteps is a pure function — no state, no side effects — called on every render.

    // wizard-step-config.ts
    import type { WizardFormValues } from './wizard.types'
    
    export type StepId =
      | 'plan'
      | 'account'
      | 'billing'
      | 'team'
      | 'review'
    
    export interface StepConfig {
      id: StepId
      always: boolean
      visible: (values: WizardFormValues) => boolean
    }
    
    export const STEP_CONFIGS: StepConfig[] = [
      { id: 'plan',    always: true,  visible: () => true },
      { id: 'account', always: true,  visible: () => true },
      { id: 'billing', always: true,  visible: () => true },
      {
        id: 'team',
        always: false,
        visible: (v) => v.plan === 'team' || v.plan === 'enterprise',
      },
      { id: 'review',  always: true,  visible: () => true },
    ]
    
    export function getVisibleSteps(values: WizardFormValues): StepConfig[] {
      return STEP_CONFIGS.filter((s) => s.always || s.visible(values))
    }
    

    Key rule: When the user goes back and changes their plan from team to solo, the team step disappears from visibleSteps immediately. The step index is always an index into this filtered list, not a fixed number.


    5. Validation — wizard.validation.ts

    One Zod schema per step, applied to the full form values. The step schema only validates the fields it owns — unrelated fields are ignored by Zod.

    // wizard.validation.ts
    import { z } from 'zod'
    
    // ── Per-step schemas ─────────────────────────────────────────────────────
    
    export const stepPlanSchema = z.object({
      plan: z.enum(['solo', 'team', 'enterprise'], {
        errorMap: () => ({ message: 'Please select a plan.' }),
      }),
    })
    
    export const stepAccountSchema = z
      .object({
        email: z.string().email('Enter a valid email address.'),
        password: z.string().min(8, 'Password must be at least 8 characters.'),
        confirmPassword: z.string(),
      })
      .refine((v) => v.password === v.confirmPassword, {
        message: 'Passwords do not match.',
        path: ['confirmPassword'],
      })
    
    export const stepBillingSchema = z.object({
      billingInterval: z.enum(['monthly', 'annual'], {
        errorMap: () => ({ message: 'Please select a billing interval.' }),
      }),
    })
    
    export const stepTeamSchema = z.object({
      teamName: z.string().min(1, 'Team name is required.'),
      teamSlug: z
        .string()
        .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lowercase letters, numbers, and hyphens only.'),
    })
    
    // ── Full schema (used at final submit on the server) ────────────────────
    //
    // confirmPassword is intentionally absent — it is stripped by the client
    // before calling submitWizard() and is never validated server-side.
    // The match check is done at the account step (stepAccountSchema).
    //
    // Uses superRefine so conditional validation (team fields required when
    // plan !== 'solo') can be expressed in one place.
    export const fullWizardSchema = z
      .object({
        plan: z.enum(['solo', 'team', 'enterprise']),
        email: z.string().email(),
        password: z.string().min(8),
        billingInterval: z.enum(['monthly', 'annual']),
        teamName: z.string().optional(),
        teamSlug: z.string().optional(),
        teamMembers: z
          .array(
            z.object({
              id: z.string(),
              email: z.string().email('Each member needs a valid email.'),
              role: z.enum(['admin', 'member']),
            }),
          )
          .optional(),
      })
      .superRefine((data, ctx) => {
        // Team fields required when plan is not solo
        if (data.plan !== 'solo') {
          if (!data.teamName?.trim()) {
            ctx.addIssue({ code: 'custom', path: ['teamName'], message: 'Team name is required.' })
          }
          if (!data.teamSlug?.trim()) {
            ctx.addIssue({ code: 'custom', path: ['teamSlug'], message: 'Team slug is required.' })
          }
        }
      })
    

    6. Form hook — useWizardForm.ts

    One useForm call for the entire wizard. No onSubmit at the form level — each step validates independently, and the final submit is triggered explicitly.

    // useWizardForm.ts
    'use client'
    
    import { useForm } from '@tanstack/react-form'
    import type { WizardFormValues } from './wizard.types'
    
    export const defaultWizardValues: WizardFormValues = {
      plan: 'solo',
      email: '',
      password: '',
      confirmPassword: '',
      billingInterval: 'monthly',
      teamName: '',
      teamSlug: '',
      teamMembers: [],
    }
    
    export function useWizardForm(
      initialValues: WizardFormValues = defaultWizardValues,
    ) {
      return useForm({ defaultValues: initialValues })
    }
    
    // Exported type so step components can be typed without importing useForm directly
    export type WizardForm = ReturnType<typeof useWizardForm>
    
    export function resetWizardForm(form: WizardForm): WizardFormValues {
      form.reset(defaultWizardValues)
      return defaultWizardValues
    }
    

    7. Step navigation hook — useWizardStep.ts

    This hook manages: visible steps, current step index, completed-step tracking, forward/back navigation, and per-step Zod validation.

    The complete file:

    // useWizardStep.ts
    'use client'
    
    import { useState, useCallback, useMemo, useEffect } from 'react'
    import type { ZodTypeAny } from 'zod'
    import { getVisibleSteps, type StepId } from './wizard-step-config'
    import type { WizardFormValues } from './wizard.types'
    import type { WizardForm } from './useWizardForm'
    import {
      stepPlanSchema,
      stepAccountSchema,
      stepBillingSchema,
      stepTeamSchema,
    } from './wizard.validation'
    
    // ── Validator and field-prefix registries ────────────────────────────────
    
    const stepValidators: Record<Exclude<StepId, 'review'>, ZodTypeAny> = {
      plan:    stepPlanSchema,
      account: stepAccountSchema,
      billing: stepBillingSchema,
      team:    stepTeamSchema,
    }
    
    // Each step owns these field name prefixes.
    // Errors are cleared and set only for the active step's prefixes,
    // so stale errors from other steps never leak through.
    const stepFieldPrefixes: Record<Exclude<StepId, 'review'>, string[]> = {
      plan:    ['plan'],
      account: ['email', 'password', 'confirmPassword'],
      billing: ['billingInterval'],
      team:    ['teamName', 'teamSlug', 'teamMembers'],
    }
    
    // ── Helpers ──────────────────────────────────────────────────────────────
    
    function matchesFieldPrefix(fieldName: string, prefix: string): boolean {
      return (
        fieldName === prefix ||
        fieldName.startsWith(`${prefix}.`) ||
        fieldName.startsWith(`${prefix}[`)
      )
    }
    
    function pathToFieldName(path: ReadonlyArray<PropertyKey>): string {
      return path.reduce<string>((acc, part) => {
        if (typeof part === 'number') return `${acc}[${part}]`
        if (typeof part !== 'string') return acc
        return acc ? `${acc}.${part}` : part
      }, '')
    }
    
    function clearStepErrors(form: WizardForm, stepId: Exclude<StepId, 'review'>) {
      const prefixes = stepFieldPrefixes[stepId]
      for (const fieldName of Object.keys(form.state.fieldMeta)) {
        if (!prefixes.some((p) => matchesFieldPrefix(fieldName, p))) continue
        form.setFieldMeta(fieldName as never, (prev) => ({
          ...prev,
          errorMap: { ...prev?.errorMap, onSubmit: undefined },
        }))
      }
    }
    
    function applyStepErrors(
      form: WizardForm,
      errors: Record<string, string[]>,
      stepId: Exclude<StepId, 'review'>,
    ) {
      const rootField = stepFieldPrefixes[stepId][0]
    
      for (const [fieldName, messages] of Object.entries(errors)) {
        form.setFieldMeta(fieldName as never, (prev) => ({
          ...prev,
          isTouched: true,
          errorMap: {
            ...prev?.errorMap,
            onSubmit: messages.map((message) => ({ message })),
          },
        }))
      }
    
      // Ensure the root field is also touched so the step component knows to show errors
      if (!Object.keys(errors).includes(rootField)) {
        form.setFieldMeta(rootField as never, (prev) => ({
          ...prev,
          isTouched: true,
        }))
      }
    }
    
    // ── Hook ─────────────────────────────────────────────────────────────────
    
    interface UseWizardStepOptions {
      formValues: WizardFormValues
      initialStep?: { currentStepIndex: number; completedStepIds: StepId[] }
    }
    
    export function useWizardStep({ formValues, initialStep }: UseWizardStepOptions) {
      const [currentStepIndex, setCurrentStepIndex] = useState(
        () => initialStep?.currentStepIndex ?? 0,
      )
      const [completedSteps, setCompletedSteps] = useState<Set<StepId>>(
        () => new Set(initialStep?.completedStepIds ?? []),
      )
    
      // Pure function — recomputes from live form values on every render
      const visibleSteps = useMemo(() => getVisibleSteps(formValues), [formValues])
    
      // Clamp index when steps disappear (e.g. user goes back and changes plan)
      useEffect(() => {
        if (currentStepIndex >= visibleSteps.length && visibleSteps.length > 0) {
          const newIndex = visibleSteps.length - 1
          setCurrentStepIndex(newIndex)
          setCompletedSteps((old) => {
            const allowed = new Set(visibleSteps.slice(0, newIndex).map((s) => s.id))
            return new Set([...old].filter((id) => allowed.has(id)))
          })
        }
      }, [currentStepIndex, visibleSteps])
    
      const currentStep = visibleSteps[currentStepIndex]
      const isFirstStep = currentStepIndex === 0
      const isLastStep  = currentStepIndex === visibleSteps.length - 1
    
      // ── Per-step validation ─────────────────────────────────────────────
    
      const validateCurrentStep = useCallback(
        async (form: WizardForm): Promise<boolean> => {
          if (!currentStep || currentStep.id === 'review') return true
    
          clearStepErrors(form, currentStep.id)
    
          const result = stepValidators[currentStep.id].safeParse(form.state.values)
    
          if (!result.success) {
            const errors = result.error.issues.reduce<Record<string, string[]>>((acc, issue) => {
              const key = pathToFieldName(issue.path) || stepFieldPrefixes[currentStep.id][0]
              acc[key] = [...(acc[key] ?? []), issue.message]
              return acc
            }, {})
            applyStepErrors(form, errors, currentStep.id)
            return false
          }
    
          return true
        },
        [currentStep],
      )
    
      // ── Navigation ──────────────────────────────────────────────────────
    
      const goNext = useCallback(() => {
        if (!isLastStep && currentStep) {
          setCompletedSteps((prev) => new Set(prev).add(currentStep.id))
          setCurrentStepIndex((prev) => Math.min(prev + 1, visibleSteps.length - 1))
        }
      }, [isLastStep, currentStep, visibleSteps.length])
    
      const goBack = useCallback(() => {
        if (isFirstStep) return
        const nextIndex = Math.max(currentStepIndex - 1, 0)
        setCompletedSteps((old) => {
          const allowed = new Set(visibleSteps.slice(0, nextIndex).map((s) => s.id))
          return new Set([...old].filter((id) => allowed.has(id)))
        })
        setCurrentStepIndex(nextIndex)
      }, [isFirstStep, currentStepIndex, visibleSteps])
    
      const resetStepNavigation = useCallback(() => {
        setCurrentStepIndex(0)
        setCompletedSteps(new Set())
      }, [])
    
      return {
        currentStepIndex,
        currentStep,
        visibleSteps,
        completedSteps,
        isFirstStep,
        isLastStep,
        validateCurrentStep,
        goNext,
        goBack,
        resetStepNavigation,
      }
    }
    

    8. Step components

    Each step receives form: WizardForm as a prop and uses TanStack Form's render-props API.

    Basic field pattern

    // steps/StepAccount.tsx
    'use client'
    
    import { Field, FieldLabel, FieldError } from '@/components/ui/field'
    import { Input } from '@/components/ui/input'
    import type { WizardForm } from '../useWizardForm'
    
    export function StepAccount({ form }: { form: WizardForm }) {
      return (
        <div className="space-y-4">
          <form.Field name="email">
            {(field) => (
              <Field>
                <FieldLabel htmlFor={field.name}>Email address</FieldLabel>
                <Input
                  id={field.name}
                  type="email"
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
                />
                <FieldError errors={field.state.meta.errors} />
              </Field>
            )}
          </form.Field>
    
          <form.Field name="password">
            {(field) => (
              <Field>
                <FieldLabel htmlFor={field.name}>Password</FieldLabel>
                <Input
                  id={field.name}
                  type="password"
                  value={field.state.value}
                  =
                  = => field.handleChange(e.target.value)}
                />
                
              
            )}
          
    
          
            {(field) => (
              
                Confirm password
                 field.handleChange(e.target.value)}
                />
                
              
            )}
          
        
      )
    }
    

    Remaining step components

    StepPlan.tsx — radio group picking a plan:

    // steps/StepPlan.tsx
    'use client'
    
    import { Field, FieldLabel, FieldError } from '@/components/ui/field'
    import type { WizardForm } from '../useWizardForm'
    import type { Plan } from '../wizard.types'
    
    const PLANS: { value: Plan; label: string; description: string }[] = [
      { value: 'solo',       label: 'Solo',       description: 'Just you.' },
      { value: 'team',       label: 'Team',       description: 'Up to 10 members.' },
      { value: 'enterprise', label: 'Enterprise', description: 'Unlimited members.' },
    ]
    
    export function StepPlan({ form }: { form: WizardForm }) {
      return (
        <form.Field name="plan">
          {(field) => (
            <Field>
              <FieldLabel>Choose a plan</FieldLabel>
              <div className="space-y-2">
                {PLANS.map((plan) => (
                  <label
                    key={plan.value}
                    className="flex cursor-pointer items-start gap-3 rounded border p-3 hover:bg-muted"
                  >
                    <input
                      type="radio"
                      name={field.name}
                      value={plan.value}
                      checked={field.state.value === plan.value}
                      onChange={() => field.handleChange(plan.value)}
                      className="mt-0.5"
                    />
                    <div>
                      <p className="font-medium">{plan.label}</p>
                      <p className="text-sm text-muted-foreground">{plan.description}</p>
                    </div>
                  </label>
                ))}
              </div>
              
            
          )}
        
      )
    }
    

    StepBilling.tsx — toggle between monthly and annual:

    // steps/StepBilling.tsx
    'use client'
    
    import { Field, FieldLabel, FieldError } from '@/components/ui/field'
    import type { WizardForm } from '../useWizardForm'
    import type { BillingInterval } from '../wizard.types'
    
    const OPTIONS: { value: BillingInterval; label: string }[] = [
      { value: 'monthly', label: 'Monthly' },
      { value: 'annual',  label: 'Annual (save 20%)' },
    ]
    
    export function StepBilling({ form }: { form: WizardForm }) {
      return (
        <form.Field name="billingInterval">
          {(field) => (
            <Field>
              <FieldLabel>Billing interval</FieldLabel>
              <div className="flex gap-3">
                {OPTIONS.map((opt) => (
                  <label
                    key={opt.value}
                    className="flex cursor-pointer items-center gap-2 rounded border px-4 py-2"
                  >
                    <input
                      type="radio"
                      name={field.name}
                      value={opt.value}
                      checked={field.state.value === opt.value}
                      onChange={() => field.handleChange(opt.value)}
                    />
                    {opt.label}
                  </label>
                ))}
              </div>
              <FieldError errors={field.state.meta.errors} />
            </Field>
          )}
        </form.Field>
      )
    }
    

    StepReview.tsx — read-only summary before submit:

    // steps/StepReview.tsx
    'use client'
    
    import { useStore } from '@tanstack/react-form'
    import type { WizardForm } from '../useWizardForm'
    
    export function StepReview({ form }: { form: WizardForm }) {
      const values = useStore(form.store, (s) => s.values)
    
      return (
        <div className="space-y-4">
          <h2 className="text-lg font-semibold">Review your details</h2>
          <dl className="divide-y rounded border">
            <Row label="Plan"    value={values.plan} />
            <Row label="Email"   value={values.email} />
            <Row label="Billing" value={values.billingInterval} />
            {values.plan !== 'solo' && (
              <>
                <Row label="Team name" value={values.teamName} />
                <Row label="Team slug" value={values.teamSlug} />
                <Row
                  label="Members"
                  value={values.teamMembers.length > 0
                    ? values.teamMembers.map((m) => m.email).join(', ')
                    : 'None added'}
                />
              </>
            )}
          </dl>
          <p className="text-sm text-muted-foreground">
            Click Submit to create your account.
          </p>
        </div>
      )
    }
    
    function Row({ label, value }: { label: string; value: string }) {
      return (
        <div className="flex justify-between px-3 py-2 text-sm">
          <dt className="font-medium text-muted-foreground">{label}</dt>
          <dd>{value || '—'}</dd>
        </div>
      )
    }
    

    Watching dependent values

    Use form.Subscribe to re-render only when specific values change:

    <form.Subscribe selector={(s) => s.values.plan}>
      {(plan) =>
        plan === 'enterprise' && (
          <p className="text-sm text-muted-foreground">
            Enterprise billing is invoiced annually.
          </p>
        )
      }
    </form.Subscribe>
    

    Select / RadioGroup type casting

    onValueChange from shadcn Select emits string, but the field expects the narrower union type. Cast it:

    <Select
      value={field.state.value}
      onValueChange={(v) => field.handleChange(v as typeof field.state.value)}
    >
    

    9. Draft persistence — wizard-persist.ts

    Critical: never write password or confirmPassword to localStorage. Strip them before saving.

    // wizard-persist.ts
    import type { WizardFormValues } from './wizard.types'
    import type { StepId } from './wizard-step-config'
    
    const STORAGE_VERSION = 1 as const
    const STORAGE_KEY = `my-wizard:v${STORAGE_VERSION}`
    
    interface WizardDraft {
      version: typeof STORAGE_VERSION
      currentStepIndex: number
      completedStepIds: StepId[]
      values: Partial<Omit<WizardFormValues, 'password' | 'confirmPassword'>>
    }
    
    // Strip passwords before they can reach localStorage
    function sanitizeDraftValues(
      values: WizardFormValues,
    ): Partial<Omit<WizardFormValues, 'password' | 'confirmPassword'>> {
      const { password: _p, confirmPassword: _c, ...safeValues } = values
      return safeValues
    }
    
    export function saveWizardDraft(params: {
      values: WizardFormValues
      currentStepIndex: number
      completedStepIds: StepId[]
    }) {
      try {
        const envelope: WizardDraft = {
          version: STORAGE_VERSION,
          currentStepIndex: params.currentStepIndex,
          completedStepIds: params.completedStepIds,
          values: sanitizeDraftValues(params.values),
        }
        localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope))
      } catch {
        // localStorage may be unavailable (private browsing, storage full)
      }
    }
    
    export function loadWizardDraft(): WizardDraft | null {
      try {
        const raw = localStorage.getItem(STORAGE_KEY)
        if (!raw) return null
        const parsed = JSON.parse(raw) as unknown
        if (
          !parsed ||
          typeof parsed !== 'object' ||
          (parsed as WizardDraft).version !== STORAGE_VERSION
        ) {
          return null
        }
        return parsed as WizardDraft
      } catch {
        return null
      }
    }
    
    export function clearWizardDraft() {
      try { localStorage.removeItem(STORAGE_KEY) } catch {}
    }
    
    // Merge persisted values with defaults.
    // Passwords always start empty — they were never stored.
    export function mergePersistedValues(
      partial: Partial<WizardFormValues> | undefined,
      defaults: WizardFormValues,
    ): WizardFormValues {
      return {
        ...defaults,
        ...partial,
        // These are never persisted, always reset
        password: '',
        confirmPassword: '',
      }
    }
    

    Use a relaxed draft schema if you add Zod validation to the load path. Work-in-progress data (empty strings, zeroes) would fail the strict submit-time schema and silently result in an empty draft. Keep draft validation permissive and save submit-time strictness for fullWizardSchema.


    10. Shell component — WizardShell.tsx

    The shell wires everything together: hydration, autosave, step rendering, navigation, and submit.

    // WizardShell.tsx
    'use client'
    
    import { useState, useLayoutEffect, useEffect, useRef } from 'react'
    import { useStore } from '@tanstack/react-form'
    import { Loader2 } from 'lucide-react'
    import { useWizardForm, defaultWizardValues } from './useWizardForm'
    import type { WizardForm } from './useWizardForm'
    import { useWizardStep } from './useWizardStep'
    import {
      loadWizardDraft,
      saveWizardDraft,
      clearWizardDraft,
      mergePersistedValues,
    } from './wizard-persist'
    import { submitWizard } from './wizard.actions'
    import { applyServerErrors } from './wizard-error-mapping'  // see §12
    import { StepPlan } from './steps/StepPlan'
    import { StepAccount } from './steps/StepAccount'
    import { StepBilling } from './steps/StepBilling'
    import { StepTeam } from './steps/StepTeam'
    import { StepReview } from './steps/StepReview'
    import type { StepId } from './wizard-step-config'
    import type { WizardFormValues } from './wizard.types'
    
    const STEP_COMPONENTS: Record<Exclude<StepId, 'review'>, React.ComponentType<{ form: WizardForm }>> = {
      plan:    StepPlan,
      account: StepAccount,
      billing: StepBilling,
      team:    StepTeam,
    }
    
    // ── Outer shell — handles hydration ─────────────────────────────────────
    
    export function WizardShell() {
      const [hydrated, setHydrated] = useState(false)
      const [initialValues, setInitialValues] = useState<WizardFormValues | null>(null)
      const [initialNav, setInitialNav] = useState<{
        currentStepIndex: number
        completedStepIds: StepId[]
      } | undefined>()
    
      // useLayoutEffect fires before paint, so the form is initialized
      // with correct values before the first render — no flash of empty state.
      useLayoutEffect(() => {
        const draft = loadWizardDraft()
        if (draft) {
          setInitialValues(mergePersistedValues(draft.values, defaultWizardValues))
          setInitialNav({
            currentStepIndex: draft.currentStepIndex,
            completedStepIds: draft.completedStepIds,
          })
        } else {
          setInitialValues({ ...defaultWizardValues })
        }
        setHydrated(true)
      }, [])
    
      if (!hydrated || !initialValues) {
        return (
          <div className="flex min-h-screen items-center justify-center">
            <Loader2 className="size-8 animate-spin text-muted-foreground" aria-label="Loading" />
          </div>
        )
      }
    
      return <WizardBody initialValues={initialValues} initialNav={initialNav} />
    }
    
    // ── Inner body — owns form, nav, and autosave ────────────────────────────
    
    function WizardBody({
      initialValues,
      initialNav,
    }: {
      initialValues: WizardFormValues
      initialNav?: { currentStepIndex: number; completedStepIds: StepId[] }
    }) {
      const form = useWizardForm(initialValues)
      const [submitted, setSubmitted]     = useState(false)
      const [submitting, setSubmitting]   = useState(false)
      const [validating, setValidating]   = useState(false)
      const [submitError, setSubmitError] = useState<string | null>(null)
    
      const formValues = useStore(form.store, (s) => s.values)
    
      const {
        currentStepIndex,
        currentStep,
        visibleSteps,
        completedSteps,
        isLastStep,
        validateCurrentStep,
        goNext,
        goBack,
      } = useWizardStep({ formValues, initialStep: initialNav })
    
      // ── Autosave ────────────────────────────────────────────────────────
    
      // Snapshot ref so event-listener callbacks always read current values
      // without being re-registered on every render.
      const snapshotRef = useRef<{
        form: WizardForm
        currentStepIndex: number
        completedStepIds: StepId[]
      } | null>(null)
      snapshotRef.current = {
        form,
        currentStepIndex,
        completedStepIds: Array.from(completedSteps),
      }
    
      // Flush on tab switch or browser close
      useEffect(() => {
        const flush = () => {
          if (submitted) return
          const snap = snapshotRef.current
          if (!snap) return
          saveWizardDraft({
            values: snap.form.state.values,
            currentStepIndex: snap.currentStepIndex,
            completedStepIds: snap.completedStepIds,
          })
        }
    
        const handleVisibilityChange = () => {
          if (document.visibilityState === 'hidden') flush()
        }
    
        window.addEventListener('pagehide', flush)
        document.addEventListener('visibilitychange', handleVisibilityChange)
    
        return () => {
          window.removeEventListener('pagehide', flush)
          document.removeEventListener('visibilitychange', handleVisibilityChange)
        }
      }, [submitted])
    
      // Debounced save on every value change
      useEffect(() => {
        if (submitted) return
        const id = window.setTimeout(() => {
          saveWizardDraft({
            values: formValues,
            currentStepIndex,
            completedStepIds: Array.from(completedSteps),
          })
        }, 350)
        return () => window.clearTimeout(id)
      }, [submitted, formValues, currentStepIndex, completedSteps])
    
      // ── Navigation handlers ─────────────────────────────────────────────
    
      const handleNext = async () => {
        if (validating) return
        setSubmitError(null)
        setValidating(true)
        try {
          const valid = await validateCurrentStep(form)
          if (!valid) return
        } finally {
          setValidating(false)
        }
        goNext()
      }
    
      const handleBack = () => {
        if (validating) return
        setSubmitError(null)
        goBack()
      }
    
      // ── Submit ──────────────────────────────────────────────────────────
    
      const handleSubmit = async () => {
        if (submitting || validating) return
        setSubmitError(null)
    
        // Validate the last step before submitting
        if (currentStep && currentStep.id !== 'review') {
          setValidating(true)
          try {
            const valid = await validateCurrentStep(form)
            if (!valid) return
          } finally {
            setValidating(false)
          }
        }
    
        setSubmitting(true)
        try {
          // Strip confirmPassword — it was validated client-side and must not be sent to the server
          const { confirmPassword: _c, ...serverInput } = form.state.values
          const result = await submitWizard(serverInput)
    
          if (result.ok) {
            setSubmitted(true)
            clearWizardDraft()
            if (result.redirectUrl) {
              window.location.assign(result.redirectUrl)
              return
            }
            return
          }
    
          // Map server field errors back into the form (see §12)
          if (result.fieldErrors) {
            applyServerErrors(form, result.fieldErrors)
          }
          setSubmitError(result.message ?? 'Submission failed. Please try again.')
        } finally {
          setSubmitting(false)
        }
      }
    
      // ── Render ──────────────────────────────────────────────────────────
    
      const StepComponent =
        currentStep && currentStep.id !== 'review'
          ? STEP_COMPONENTS[currentStep.id]
          : null
    
      const progress =
        visibleSteps.length > 0
          ? Math.round(((currentStepIndex + 1) / visibleSteps.length) * 100)
          : 0
    
      return (
        <div className="flex min-h-screen flex-col">
          {/* Progress header */}
          <header className="border-b px-4 py-3">
            <div className="mx-auto max-w-xl">
              <p className="text-sm text-muted-foreground">
                Step {currentStepIndex + 1} of {visibleSteps.length}
              </p>
              <div className="mt-1 h-1 rounded-full bg-muted">
                <div
                  className="h-1 rounded-full bg-primary transition-all"
                  style={{ width: `${progress}%` }}
                />
              </div>
            </div>
          </header>
    
          {/* Step content */}
          <main className="mx-auto w-full max-w-xl flex-1 px-4 py-8">
            {currentStep?.id === 'review' ? (
              <StepReview key="review" form={form} />
            ) : StepComponent ? (
              <StepComponent key={currentStep!.id} form={form} />
            ) : null}
    
            {submitError && (
              < = =>
                {submitError}
              
            )}
          
    
          {/* Bottom navigation */}
          
            
              
                Back
              
              
                {submitting
                  ? 'Submitting…'
                  : validating
                  ? 'Checking…'
                  : isLastStep
                  ? 'Submit'
                  : 'Next'}
              
            
          
        
      )
    }
    

    11. Server action — wizard.actions.ts

    The action receives the full form values, normalizes, validates, and creates records. Order matters — create in dependency order to avoid orphaned records.

    // wizard.actions.ts
    'use server'
    
    import { fullWizardSchema } from './wizard.validation'
    import type { WizardServerInput } from './wizard.types'
    
    export interface WizardActionResult {
      ok: boolean
      message?: string
      redirectUrl?: string
      fieldErrors?: Record<string, string[]>
    }
    
    // Accepts WizardServerInput (confirmPassword already stripped by the client).
    export async function submitWizard(input: WizardServerInput): Promise<WizardActionResult> {
      // 1. Full validation (Zod)
      const parsed = fullWizardSchema.safeParse(input)
      if (!parsed.success) {
        const fieldErrors = parsed.error.issues.reduce<Record<string, string[]>>((acc, issue) => {
          const key = issue.path.join('.') || 'form'
          acc[key] = [...(acc[key] ?? []), issue.message]
          return acc
        }, {})
        return { ok: false, message: 'Please fix the errors below.', fieldErrors }
      }
    
      const data = parsed.data
    
      // 2. Create records in dependency order
      let accountId: string | null = null
      try {
        // Create account first
        const account = await createAccount({
          email: data.email,
          password: data.password,  // hash this in your real implementation
        })
        accountId = account.id
    
        // Create subscription
        await createSubscription({
          accountId: account.id,
          plan: data.plan,
          billingInterval: data.billingInterval,
        })
    
        // Create team only when plan requires it
        if (data.plan !== 'solo') {
          await createTeam({
            accountId: account.id,
            name: data.teamName!,
            slug: data.teamSlug!,
            members: data.teamMembers ?? [],
          })
        }
    
        return { ok: true, redirectUrl: '/dashboard' }
      } catch (error) {
        // Roll back the account if later steps fail — prevents orphaned records
        if (accountId) {
          await deleteAccount(accountId).catch(() => {})
        }
    
        // Map known DB constraint violations to field errors
        if (isSlugTakenError(error)) {
          return {
            ok: false,
            message: 'That team slug is already taken.',
            fieldErrors: { teamSlug: ['This slug is already in use. Choose a different one.'] },
          }
        }
        if (isEmailTakenError(error)) {
          return {
            ok: false,
            message: 'An account with that email already exists.',
            fieldErrors: { email: ['An account with this email already exists.'] },
          }
        }
    
        const message = error instanceof Error ? error.message : 'An unexpected error occurred.'
        return { ok: false, message }
      }
    }
    
    // Placeholder helpers — replace with your ORM / API calls
    async function createAccount(_data: { email: string; password: string }) {
      return { id: 'acc_123' }
    }
    async function createSubscription(_data: { accountId: string; plan: string; billingInterval: string }) {}
    async function createTeam(_data: { accountId: string; name: string; slug: string; members: unknown[] }) {}
    async function deleteAccount(_id: string) {}
    function isSlugTakenError(_e: unknown): boolean { return false }
    function isEmailTakenError(_e: unknown): boolean { return false }
    

    12. Mapping server errors back into the form

    When the server action returns fieldErrors, apply them into TanStack Form field meta so errors appear on the right inputs — even after a full schema failure or a DB constraint violation.

    // wizard-error-mapping.ts
    import type { WizardForm } from './useWizardForm'
    
    export function applyServerErrors(
      form: WizardForm,
      fieldErrors: Record<string, string[]>,
    ) {
      for (const [fieldName, messages] of Object.entries(fieldErrors)) {
        form.setFieldMeta(fieldName as never, (prev) => ({
          ...prev,
          isTouched: true,
          errorMap: {
            ...prev?.errorMap,
            onSubmit: messages.map((message) => ({ message })),
          },
        }))
      }
    }
    

    This is the same mechanism used for per-step validation — field errors are always written into errorMap.onSubmit so that <FieldError errors={field.state.meta.errors} /> picks them up automatically.


    13. Advanced: array steps

    When a step manages a dynamic list, use TanStack Form's array helpers. This is an extension of StepTeam that lets the user add team members.

    // steps/StepTeam.tsx
    'use client'
    
    import type { WizardForm } from '../useWizardForm'
    import { Field, FieldLabel, FieldError } from '@/components/ui/field'
    import { Input } from '@/components/ui/input'
    
    function createDefaultMember() {
      return { id: `member-${Date.now()}`, email: '', role: 'member' as const }
    }
    
    export function StepTeam({ form }: { form: WizardForm }) {
      return (
        <div className="space-y-6">
          <form.Field name="teamName">
            {(field) => (
              <Field>
                <FieldLabel htmlFor={field.name}>Team name</FieldLabel>
                <Input
                  id={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
                />
                <FieldError errors={field.state.meta.errors} />
              </Field>
            )}
          </form.Field>
    
          <form.Field name="teamSlug">
            {(field) => (
              <Field>
                <FieldLabel htmlFor={field.name}>Team URL slug</FieldLabel>
                <Input
                  id={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange= => field.handleChange(e.target.value)}
                  placeholder="my-team"
                />
                
              
            )}
          
    
          {/* Team members */}
          
            Team members
            
              {(field) => (
                
                  {field.state.value.map((_, i) => (
                    
                      
                        {(memberField) => (
                           memberField.handleChange(e.target.value)}
                            placeholder="colleague@company.com"
                            className="flex-1"
                          />
                        )}
                      
                       field.removeValue(i)}
                        className="text-sm text-red-600"
                      >
                        Remove
                      
                    
                  ))}
                   field.pushValue(createDefaultMember())}
                    className="text-sm underline"
                  >
                    + Add member
                  
                
              )}
            
          
        </div>
      )
    }
    

    Why arrays, not records: TanStack Form v1 requires field names known at compile time. Dynamic keys like `members.${id}` do not type-check. Arrays of { id, value } objects avoid this.


    14. Advanced: credential sidecar

    If you want to persist a password hint across sessions (so users know they already set a password), hash it server-side with HMAC-SHA256 and store only the hash. The hash is one-way — it cannot be used to log in.

    // credential-sidecar.actions.ts
    'use server'
    import crypto from 'node:crypto'
    
    export async function hashPassword(password: string): Promise<string | null> {
      const secret = process.env.WIZARD_CREDENTIAL_SECRET
      if (!secret) return null  // silently skip if env var is absent
      return crypto.createHmac('sha256', secret).update(password).digest('hex')
    }
    
    // credential-persist.ts (client)
    const CREDENTIAL_KEY = 'my-wizard:credentials:v1'
    
    export function saveCredentialHash(email: string, passwordHash: string) {
      try { localStorage.setItem(CREDENTIAL_KEY, JSON.stringify({ email, passwordHash })) } catch {}
    }
    
    export function clearCredentialHash() {
      try { localStorage.removeItem(CREDENTIAL_KEY) } catch {}
    }
    

    Call hashPassword and saveCredentialHash after the account step passes validation. Call clearCredentialHash after a successful submit alongside clearWizardDraft.

    Add WIZARD_CREDENTIAL_SECRET to your environment. Its absence is safe — the sidecar is silently skipped.


    15. Advanced: dev debug panel

    During development, render a panel below the wizard that shows the live form state and lets you paste JSON to set the form to any state. Gate it with process.env.NODE_ENV === 'development'.

    // DevDebugPanel.tsx
    'use client'
    import { useState } from 'react'
    import { useStore } from '@tanstack/react-form'
    import type { WizardForm } from './useWizardForm'
    
    export function DevDebugPanel({ form }: { form: WizardForm }) {
      const state = useStore(form.store, (s) => s)
      const [editor, setEditor] = useState(() => JSON.stringify(state, null, 2))
      const [error, setError] = useState<string | null>(null)
    
      const apply = () => {
        try {
          const parsed = JSON.parse(editor)
          const values = (parsed?.values ?? parsed) as Record<string, unknown>
          for (const [key, value] of Object.entries(values)) {
            form.setFieldValue(key as never, value)
          }
          setEditor(JSON.stringify({ ...state, values }, null, 2))
          setError(null)
        } catch (e) {
          setError(e instanceof Error ? e.message : 'Invalid JSON')
        }
      }
    
      return (
        <details className="border-t bg-slate-950 px-4 py-4 text-slate-50">
          <summary className="cursor-pointer text-sm font-semibold">Dev — form state</summary>
          <div className="mt-3 grid gap-4 lg:grid-cols-2">
            <pre className="max-h-96 overflow-auto text-xs">{JSON.stringify(state, null, 2)}</pre>
            <div className="space-y-2">
              <textarea
                value={editor}
                onChange={(e) => setEditor(e.target.value)}
                spellCheck={false}
                className="min-h-64 w-full rounded border bg-slate-900 p-2 font-mono text-xs"
              />
              {error && <p className="text-xs text-rose-400">{error}</p>}
              <button
                type="button"
                onClick={apply}
                className="rounded border border-emerald-500 px-3 py-1 text-xs text-emerald-300"
              >
                Apply to form
              </button>
            </div>
          </div>
        </details>
      )
    }
    

    Usage in WizardBody:

    {process.env.NODE_ENV === 'development' && <DevDebugPanel form={form} />}
    

    16. Adding a new step — checklist

    1. Add the step ID to the StepId union in wizard-step-config.ts
    2. Add a StepConfig entry to STEP_CONFIGS with a visible predicate
    3. Write a Zod schema for the step in wizard.validation.ts
    4. Add the schema to stepValidators in useWizardStep.ts
    5. Add the step's field name prefixes to stepFieldPrefixes
    6. Add default values to defaultWizardValues in useWizardForm.ts
    7. Add the new fields to WizardFormValues in wizard.types.ts
    8. Create the step component in steps/StepNewThing.tsx
    9. Add the component to STEP_COMPONENTS in WizardShell.tsx
    10. Add publish logic for the new data to submitWizard in wizard.actions.ts

    17. Common pitfalls

    Plaintext passwords in localStorage

    saveWizardDraft stores params.values — if you pass the raw form values without stripping passwords, password and confirmPassword land in localStorage. Always call sanitizeDraftValues before building the envelope. mergePersistedValues must also explicitly reset passwords to empty strings.

    Hydration flash

    Using useEffect instead of useLayoutEffect for draft loading causes the form to render with empty defaults for one frame before the draft is applied. useLayoutEffect fires before paint, so the form has the correct state on the first render.

    Dynamic field keys in TanStack Form v1

    Field names like `stock.${id}` do not type-check because TanStack Form v1 requires compile-time field names. Use arrays of { id, value } objects instead of Record<string, value>.

    Step index not clamping

    When steps disappear (user changes a choice that removes a conditional step), currentStepIndex can be out of range for the new visible-steps array. The useEffect in useWizardStep handles this — do not remove it.

    Stale errors leaking across steps

    Without calling clearStepErrors before applyStepErrors, errors from a previous visit to a step persist when you return to it. The validation flow in useWizardStep.validateCurrentStep always clears first.

    Forgetting to clear the draft on success

    If clearWizardDraft() is not called after submit, the user who returns to the wizard URL is dropped back into the completed wizard. Always clear draft and credential sidecar on success.

    confirmPassword must be stripped before the server call

    confirmPassword is validated client-side (must match password) but must not be stored in the draft or processed by the server. Two safeguards are required: sanitizeDraftValues strips it before localStorage, and the shell destructures it out before calling submitWizard. The server action accepts WizardServerInput (which omits confirmPassword), and fullWizardSchema does not include it. If you skip either strip, TypeScript will catch the mismatch.

    Server fieldErrors not shown on inputs

    Returning fieldErrors from the server action is not enough — you must call applyServerErrors(form, result.fieldErrors) to write them into TanStack Form field meta. Without this, DB errors like "slug already taken" only appear in the generic submitError message, not on the relevant input.

    Unique constraint errors are not Zod errors

    Zod validates the shape of the input, not uniqueness. "Slug already taken" passes Zod and fails at the DB layer. Catch these explicitly in the server action and return them as fieldErrors with the relevant field name.

    I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

  • 7. Step navigation hook — `useWizardStep.ts`
  • 8. Step components
  • Basic field pattern
  • Remaining step components
  • Watching dependent values
  • Select / RadioGroup type casting
  • 9. Draft persistence — `wizard-persist.ts`
  • 10. Shell component — `WizardShell.tsx`
  • 11. Server action — `wizard.actions.ts`
  • 12. Mapping server errors back into the form
  • 13. Advanced: array steps
  • 14. Advanced: credential sidecar
  • 15. Advanced: dev debug panel
  • 16. Adding a new step — checklist
  • 17. Common pitfalls
  • Plaintext passwords in localStorage
  • Hydration flash
  • Dynamic field keys in TanStack Form v1
  • Step index not clamping
  • Stale errors leaking across steps
  • Forgetting to clear the draft on success
  • `confirmPassword` must be stripped before the server call
  • Server `fieldErrors` not shown on inputs
  • Unique constraint errors are not Zod errors
  • On this page:
    • What this guide covers
    • 1. Requirements and assumptions
    • 2. Folder structure
    • 3. Types — `wizard.types.ts`
    • 4. Step configuration — `wizard-step-config.ts`
    Blog
    Next.js
    Next.js
    About
    View resume
    onBlur
    {field.handleBlur}
    onChange
    {(e)
    <FieldError errors={field.state.meta.errors} />
    </Field>
    </form.Field>
    <form.Field name="confirmPassword">
    <Field>
    <FieldLabel htmlFor={field.name}>
    </FieldLabel>
    <Input id={field.name} type="password" value={field.state.value} onBlur={field.handleBlur} onChange={(e) =>
    <FieldError errors={field.state.meta.errors} />
    </Field>
    </form.Field>
    </div>
    <FieldError errors={field.state.meta.errors} />
    </Field>
    </form.Field>
    p
    className
    "mt-4 text-sm text-red-600"
    role
    "alert"
    </p>
    </main>
    <footer className="border-t px-4 py-3">
    <div className="mx-auto flex max-w-xl justify-between">
    <button type="button" onClick={handleBack} disabled={currentStepIndex === 0 || validating || submitting} className="rounded px-4 py-2 text-sm disabled:opacity-50" >
    </button>
    <button type="button" onClick={isLastStep ? handleSubmit : handleNext} disabled={validating || submitting} className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50" >
    </button>
    </div>
    </footer>
    </div>
    {(e)
    <FieldError errors={field.state.meta.errors} />
    </Field>
    </form.Field>
    <div className="space-y-3">
    <p className="text-sm font-medium">
    </p>
    <form.Field name="teamMembers" mode="array">
    <>
    <div key={field.state.value[i]?.id ?? i} className="flex gap-2">
    <form.Field name={`teamMembers[${i}].email`}>
    <Input type="email" value={memberField.state.value} onBlur={memberField.handleBlur} onChange={(e) =>
    </form.Field>
    <button type="button" onClick={() =>
    </button>
    </div>
    <button type="button" onClick={() =>
    </button>
    </>
    </form.Field>
    </div>