Skip to content
LuoForge/Tungsten
← All recipes

Multi-step wizard

A two-step wizard sharing one FormProvider across steps. Step navigation is local recipe state; field values and validation stay owned by FormProvider — the thin layer is not extended for step state. Per-step Next validates only that step’s fields before advancing.

Preview

Step 1 of 2

Source

apps/docs/app/recipes/multi-step-wizard/recipe.tsx
'use client';

import { useState } from 'react';
import { Button, FormField, FormProvider, Input, useForm } from '@hey-mike/tungsten';

const required = (v: unknown) => (v ? undefined : 'Required');
const isEmail = (v: unknown) =>
  typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? undefined : 'Enter a valid email';

// Step navigation is LOCAL recipe state — FormProvider is NOT extended to know
// about steps (ADR-0003 ceiling). The single provider spans both steps so all
// values collect into one submit; "Next" validates just the current step's
// fields via validateField before advancing.
function WizardSteps() {
  const form = useForm();
  const [step, setStep] = useState(0);

  const next = () => {
    const stepFields = step === 0 ? ['fullName', 'email'] : [];
    const ok = stepFields.every((name) => !form.validateField(name));
    if (ok) setStep((s) => s + 1);
  };

  return (
    <form onSubmit={form.handleSubmit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
      <p className="text-ink-3 text-2xs font-mono uppercase tracking-label">
        Step {step + 1} of 2
      </p>

      {step === 0 && (
        <>
          <FormField name="fullName" label="Full name" required validate={required}>
            <Input autoComplete="name" />
          </FormField>
          <FormField name="email" label="Email" required validate={isEmail}>
            <Input type="email" autoComplete="email" />
          </FormField>
          <Button type="button" variant="primary" size="md" onClick={next}>
            Next
          </Button>
        </>
      )}

      {step === 1 && (
        <>
          <FormField name="company" label="Company" validate={required} required>
            <Input autoComplete="organization" />
          </FormField>
          <FormField name="role" label="Role">
            <Input />
          </FormField>
          <div className="flex gap-3">
            <Button type="button" variant="ghost" size="md" onClick={() => setStep(0)}>
              Back
            </Button>
            <Button type="submit" variant="brand" size="md">
              Finish
            </Button>
          </div>
        </>
      )}
    </form>
  );
}

export default function MultiStepWizardRecipe() {
  return (
    <FormProvider>
      <WizardSteps />
    </FormProvider>
  );
}
'use client';

import { useState } from 'react';
import { Button, FormField, FormProvider, Input, useForm } from '@hey-mike/tungsten';

const required = (v: unknown) => (v ? undefined : 'Required');
const isEmail = (v: unknown) =>
  typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? undefined : 'Enter a valid email';

// Step navigation is LOCAL recipe state — FormProvider is NOT extended to know
// about steps (ADR-0003 ceiling). The single provider spans both steps so all
// values collect into one submit; "Next" validates just the current step's
// fields via validateField before advancing.
function WizardSteps() {
  const form = useForm();
  const [step, setStep] = useState(0);

  const next = () => {
    const stepFields = step === 0 ? ['fullName', 'email'] : [];
    const ok = stepFields.every((name) => !form.validateField(name));
    if (ok) setStep((s) => s + 1);
  };

  return (
    <form onSubmit={form.handleSubmit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
      <p className="text-ink-3 text-2xs font-mono uppercase tracking-label">
        Step {step + 1} of 2
      </p>

      {step === 0 && (
        <>
          <FormField name="fullName" label="Full name" required validate={required}>
            <Input autoComplete="name" />
          </FormField>
          <FormField name="email" label="Email" required validate={isEmail}>
            <Input type="email" autoComplete="email" />
          </FormField>
          <Button type="button" variant="primary" size="md" onClick={next}>
            Next
          </Button>
        </>
      )}

      {step === 1 && (
        <>
          <FormField name="company" label="Company" validate={required} required>
            <Input autoComplete="organization" />
          </FormField>
          <FormField name="role" label="Role">
            <Input />
          </FormField>
          <div className="flex gap-3">
            <Button type="button" variant="ghost" size="md" onClick={() => setStep(0)}>
              Back
            </Button>
            <Button type="submit" variant="brand" size="md">
              Finish
            </Button>
          </div>
        </>
      )}
    </form>
  );
}

export default function MultiStepWizardRecipe() {
  return (
    <FormProvider>
      <WizardSteps />
    </FormProvider>
  );
}