Multi-Step Forms

Build multi-step wizards with conditional routing, step navigation, and progress tracking.

Multi-Step Forms

Create multi-step wizards by defining multiple steps with .addStep(). Each step groups a subset of fields and can route to different next steps based on field values.


Basic Multi-Step

import { FormBuilder } from '@saastro/forms';

const config = FormBuilder.create('onboarding')
  .layout('manual')
  .columns(12)

  // Fields
  .addField('name', (f) => f.type('text').label('Name').required().columns({ default: 12, md: 6 }))
  .addField('email', (f) =>
    f.type('email').label('Email').required().email().columns({ default: 12, md: 6 }),
  )
  .addField('plan', (f) =>
    f
      .type('select')
      .label('Plan')
      .required()
      .options([
        { value: 'free', label: 'Free' },
        { value: 'pro', label: 'Pro' },
        { value: 'enterprise', label: 'Enterprise' },
      ]),
  )
  .addField('terms', (f) => f.type('checkbox').label('I accept the terms').mustBeTrue())

  // Steps
  .addStep('personal', ['name', 'email'], { defaultNext: 'plan-select' })
  .addStep('plan-select', ['plan'], { defaultNext: 'confirm' })
  .addStep('confirm', ['terms'])
  .initialStep('personal')

  // Buttons
  .buttons({
    align: 'between',
    submit: { type: 'submit', label: 'Complete' },
    next: { type: 'next', label: 'Continue' },
    back: { type: 'back', label: 'Back', variant: 'outline' },
  })

  .build();

Key points:

  • defaultNext sets the next step when no conditions match
  • The last step has no defaultNext — the submit button appears instead of next
  • .initialStep() sets the starting step (defaults to the first step added)

Step Navigation

Navigation is handled by the next and back buttons that <Form /> renders below the current step’s fields.

How It Works

  1. Next: Validates the current step’s fields, then navigates to the next step
  2. Back: Returns to the previous step via the history stack (no re-validation). Only rendered after the user has advanced at least once
  3. Submit: Only shown on the last step (where no next step resolves)

If you don’t set labels in .buttons(), the defaults are the built-in English strings — Submit, Next →, ← Back. Override them per button (as in the example above), with a locale overlay, or globally with setDefaultMessages — see the Buttons guide and the i18n guide.

Step History

The form maintains a history stack. When the user goes back, they return to the exact step they came from — even with conditional routing.

Step 1 → Step 3 → Step 5    (history: [1, 3])
         ↑ Back goes to 3, then to 1

Programmatic Navigation

<Form /> drives navigation for you. When building a custom form UI instead, the same step engine is exposed through the public useFormState hook:

import { useFormState } from '@saastro/forms';

const {
  currentStepId, // active step id
  stepHistory, // stack of visited step ids
  validateAndNext, // validate the current step → advance (or submit on the last step)
  prevStep, // pop the history stack; returns false when it's empty
  nextStep, // raw advance — skips validation and onStepChange callbacks
  isLast, // true when no next step resolves from the current values
} = useFormState({ config, fields: config.fields, steps: config.steps });

How a forward move resolves:

  1. The current step’s next conditions are evaluated against the form values, in order — the first match wins
  2. If no condition matches, defaultNext is used
  3. The current step id is pushed onto stepHistory and the resolved step becomes active
  4. If nothing resolves, the step is the last one (isLast is true) and validateAndNext() submits instead of advancing

prevStep() pops the last entry from the history stack and returns to it. nextStep(values) advances without validating and without firing step-change callbacks — prefer validateAndNext().

To react to transitions, register a callback with .onStepChange():

.onStepChange((stepId) => {
  // Note: receives the id of the step being LEFT, not the one being entered
  console.log('leaving step', stepId);
})

For step metadata (progress, completion, per-step errors), use useFormStepsInfo — see Step Metadata below.


Conditional Routing

Route to different steps based on field values using the next option:

.addStep('inquiry-type', ['type'], {
  defaultNext: 'general-info',
  next: [
    {
      conditions: [{ field: 'type', operator: 'equals', value: 'sales' }],
      operator: 'AND',
      target: 'sales-info',
    },
    {
      conditions: [{ field: 'type', operator: 'equals', value: 'support' }],
      operator: 'AND',
      target: 'support-info',
    },
  ],
})

Each entry in next is a ConditionGroup with a target step ID. Conditions are evaluated in order — the first match wins. If none match, defaultNext is used.

Condition Operators

OperatorDescriptionExample
equalsExact match{ field: 'plan', operator: 'equals', value: 'pro' }
notEqualsNot equal{ field: 'plan', operator: 'notEquals', value: 'free' }
containsString/array contains{ field: 'tags', operator: 'contains', value: 'urgent' }
notContainsDoes not contain{ field: 'name', operator: 'notContains', value: 'test' }
greaterThanNumber greater than{ field: 'age', operator: 'greaterThan', value: 18 }
lessThanNumber less than{ field: 'budget', operator: 'lessThan', value: 1000 }
greaterThanOrEqualNumber greater than or equal{ field: 'score', operator: 'greaterThanOrEqual', value: 80 }
lessThanOrEqualNumber less than or equal{ field: 'quantity', operator: 'lessThanOrEqual', value: 10 }
isTrueValue is true{ field: 'premium', operator: 'isTrue' }
isFalseValue is false{ field: 'newsletter', operator: 'isFalse' }
isEmptyEmpty, null, or undefined{ field: 'phone', operator: 'isEmpty' }
isNotEmptyHas a value{ field: 'company', operator: 'isNotEmpty' }

AND / OR Grouping

Combine multiple conditions:

// AND: all conditions must be true
{
  conditions: [
    { field: 'plan', operator: 'equals', value: 'enterprise' },
    { field: 'employees', operator: 'greaterThan', value: 100 },
  ],
  operator: 'AND',
  target: 'enterprise-setup',
}

// OR: any condition can be true
{
  conditions: [
    { field: 'country', operator: 'equals', value: 'US' },
    { field: 'country', operator: 'equals', value: 'CA' },
  ],
  operator: 'OR',
  target: 'north-america-setup',
}

Step UI Components

The package exports three standalone components for step progress and navigation: StepsNavigation, StepsProgress, and StepsAccordion.

Two things to know before using them:

  • <Form /> does not render them. It only renders the current step’s fields and the navigation buttons — there are no automatic step tabs or progress bars. You compose these components yourself.
  • They read validation state through react-hook-form context, so they must be rendered inside a FormProvider. Since <Form /> doesn’t accept children, in practice that means driving the form yourself with useFormState — see Composing Step UI below.

All three take the same core props: steps, currentStepId, stepHistory, and fields.

StepsNavigation

Clickable step buttons with numbered circles, checkmark/error indicators for completed/invalid steps, and optional labels.

import { StepsNavigation } from '@saastro/forms';

<StepsNavigation
  steps={config.steps}
  currentStepId={currentStepId}
  stepHistory={stepHistory}
  fields={config.fields}
  onStepClick={(stepId) => {
    // Fires only for steps the user has already visited.
    // Back navigation is one step at a time:
    if (stepId === stepHistory[stepHistory.length - 1]) prevStep();
  }}
  onBeforeNavigate={async () => {
    // Optional gate: return false to veto the jump — e.g. validate the
    // current step before allowing navigation to a visited step.
    return await validateStep();
  }}
  orientation="horizontal" // 'horizontal' (default) | 'vertical'
  showLabels={true} // Show "Step N" labels (default: true)
  showFieldCount={false} // Show field count badges (default: false)
  allowNavigation={true} // Allow clicking visited steps (default: true)
/>;

onStepClick only fires for visited steps. The navigation API is linear — prevStep() goes back one step at a time; there is no built-in jump-to-arbitrary-step. Set allowNavigation={false} to render it as a passive indicator.

Validation on jumps: clicking a visited step does NOT validate the steps in between. If your flow requires every prior step to be valid, gate the jump with onBeforeNavigate (sync or async) — return false and the navigation is cancelled.

When to use: Multi-step forms where users benefit from an overview of all steps.

StepsProgress

Lightweight progress indicator with three visual variants:

import { StepsProgress } from '@saastro/forms';

// Progress bar (default)
<StepsProgress
  steps={config.steps}
  currentStepId={currentStepId}
  stepHistory={stepHistory}
  fields={config.fields}
  variant="bar"             // 'bar' (default) | 'dots' | 'steps'
  showLabels={false}        // Show "Paso X de Y" text (default: false)
  showPercentage={true}     // Show "60%" text (default: true)
/>

// Dots — minimal indicator
<StepsProgress variant="dots" {...stepProps} />

// Steps — numbered step indicators
<StepsProgress variant="steps" showLabels={true} {...stepProps} />

When to use: When you want something lighter than full navigation tabs. bar for simple flows, dots for minimal UI, steps for numbered progress.

StepsAccordion

Accordion layout where each step is a collapsible section. Expanding a step shows its field list and, for visited steps, its completion status.

import { StepsAccordion } from '@saastro/forms';

<StepsAccordion
  steps={config.steps}
  currentStepId={currentStepId}
  stepHistory={stepHistory}
  fields={config.fields}
  onStepSelect={(stepId) => {
    // Fires only for visited steps — wire it to your own navigation logic
  }}
  allowNavigation={true} // Allow clicking to expand/navigate (default: true)
  showFieldCount={true} // Show field count badges (default: true)
  showErrors={true} // Show error indicators (default: true)
  showProgress={true} // Show completion checkmarks (default: true)
/>;

Unlike the other two, StepsAccordion renders through injected Accordion, AccordionItem, AccordionTrigger, and AccordionContent components, so it also needs a component provider above it (FormComponentsProvider or ComponentProvider) — useComponents() throws otherwise. See the Components guide.

When to use: Long forms where users benefit from an at-a-glance overview of each step’s progress. Works well for applications, surveys, and checkout flows.

Note on language: StepsNavigation’s built-in text (“Step 1”, “Current”, the field/error count badges) comes from the package’s default messages — English out of the box, switchable globally with setDefaultMessages but not affected by the per-form i18n overlay. StepsProgress and StepsAccordion still contain hardcoded Spanish strings (“Paso 1 de 3”, “Progreso”). For a fully localized step UI, build your own with useFormStepsInfo.

Composing Step UI

A working wizard that pairs StepsProgress with a custom form driven by useFormState:

import { useFormState, StepsProgress } from '@saastro/forms';
import { FormProvider } from 'react-hook-form';

function WizardWithProgress({ config }) {
  const {
    config: resolved, // render from the returned config (plugin + i18n transforms applied)
    methods,
    currentStepId,
    stepHistory,
    validateAndNext,
    prevStep,
    isLast,
    loading,
    submitted,
    getSuccessMessage,
  } = useFormState({ config, fields: config.fields, steps: config.steps });

  if (submitted) return <p>{getSuccessMessage()}</p>;

  return (
    <FormProvider {...methods}>
      <StepsProgress
        steps={resolved.steps}
        currentStepId={currentStepId}
        stepHistory={stepHistory}
        fields={resolved.fields}
        variant="bar"
      />

      <form
        onSubmit={(e) => {
          e.preventDefault();
          void validateAndNext(); // validates only the current step; submits on the last one
        }}
      >
        {resolved.steps[currentStepId].fields.map((name) => (
          <input key={name} {...methods.register(name)} placeholder={name} />
        ))}

        {stepHistory.length > 0 && (
          <button type="button" onClick={() => prevStep()}>
            Back
          </button>
        )}
        <button type="submit" disabled={loading}>
          {isLast ? 'Submit' : 'Next'}
        </button>
      </form>
    </FormProvider>
  );
}

Call validateAndNext() directly rather than wrapping it in methods.handleSubmit: the form’s resolver validates every step’s fields at once, so empty required fields in later steps would keep an intermediate step from ever advancing. validateAndNext() validates only the current step’s fields and runs the full submit pipeline on the last one.


Step Metadata

The useFormStepsInfo hook provides rich metadata for building custom step UI. Like the step components above, it must be called below react-hook-form’s FormProvider (it reads errors and values via useFormContext) — inside a custom useFormState form, that’s the provider you already added.

import { useFormStepsInfo } from '@saastro/forms';

function CustomStepUI({ steps, currentStepId, stepHistory, fields }) {
  const info = useFormStepsInfo({ steps, currentStepId, stepHistory, fields });

  return (
    <div>
      <p>
        Step {info.steps.findIndex((s) => s.isCurrent) + 1} of {info.totalSteps}
      </p>
      <p>Progress: {info.progress}%</p>
      {info.steps.map((step) => (
        <span key={step.id} className={step.status}>
          {step.id} ({step.status})
        </span>
      ))}
    </div>
  );
}

FormStepsInfo Return Value

PropertyTypeDescription
stepsStepInfo[]Array of step metadata
currentStepIdstringCurrent step ID
totalStepsnumberTotal number of steps
completedStepsnumberNumber of completed steps
progressnumberCompletion percentage (0-100)
stepHistorystring[]Stack of visited step IDs
canGoNextbooleanWhether next navigation is possible
canGoPrevbooleanWhether back navigation is possible
getStepInfo(id)(id: string) => StepInfo | undefinedGet info for a specific step
getStepStatus(id)(id: string) => StepStatusGet status for a specific step

StepInfo

PropertyTypeDescription
idstringStep ID
fieldsstring[]Field names in this step
fieldCountnumberNumber of fields
statusStepStatus'pending', 'current', 'completed', or 'error'
hasErrorsbooleanWhether any fields have validation errors
errorCountnumberNumber of fields with errors
isVisitedbooleanWhether step has been visited
isCurrentbooleanWhether this is the active step
isCompletedbooleanWhether all fields are valid and filled
canNavigatebooleanWhether the user can navigate to this step

Per-Step Validation

Fields are validated before advancing to the next step. Only the fields in the current step are checked — fields in other steps are not validated until their step is active.

If validation fails, the user stays on the current step and error messages appear on the invalid fields. A toast notification also fires — the default message is 'Please fix the errors before continuing', overridable globally via setDefaultMessages.


Complete Example

A multi-step onboarding wizard with conditional routing:

import { FormBuilder, Form } from '@saastro/forms';

const config = FormBuilder.create('onboarding')
  .layout('manual')
  .columns(12)

  // Step 1: Account Type
  .addField('accountType', (f) =>
    f
      .type('button-radio')
      .label('Account Type')
      .required()
      .options([
        { value: 'personal', label: 'Personal' },
        { value: 'business', label: 'Business' },
      ])
      .columns({ default: 12 }),
  )

  // Step 2a: Personal Info
  .addField('fullName', (f) =>
    f.type('text').label('Full Name').required().columns({ default: 12 }),
  )
  .addField('birthDate', (f) =>
    f.type('date').label('Date of Birth').required().columns({ default: 12, md: 6 }),
  )

  // Step 2b: Business Info
  .addField('companyName', (f) =>
    f.type('text').label('Company Name').required().columns({ default: 12, md: 6 }),
  )
  .addField('employees', (f) =>
    f
      .type('select')
      .label('Company Size')
      .required()
      .options([
        { value: '1-10', label: '1-10' },
        { value: '11-50', label: '11-50' },
        { value: '51-200', label: '51-200' },
        { value: '200+', label: '200+' },
      ])
      .columns({ default: 12, md: 6 }),
  )

  // Step 3: Confirmation
  .addField('newsletter', (f) => f.type('switch').label('Subscribe to newsletter').optional())
  .addField('terms', (f) =>
    f
      .type('checkbox')
      .label('I accept the terms and conditions')
      .mustBeTrue('You must accept the terms'),
  )

  // Define steps with conditional routing
  .addStep('account-type', ['accountType'], {
    defaultNext: 'personal-info',
    next: [
      {
        conditions: [{ field: 'accountType', operator: 'equals', value: 'business' }],
        operator: 'AND',
        target: 'business-info',
      },
    ],
  })
  .addStep('personal-info', ['fullName', 'birthDate'], { defaultNext: 'confirm' })
  .addStep('business-info', ['companyName', 'employees'], { defaultNext: 'confirm' })
  .addStep('confirm', ['newsletter', 'terms'])
  .initialStep('account-type')

  .buttons({
    align: 'between',
    submit: { type: 'submit', label: 'Create Account' },
    next: { type: 'next', label: 'Continue' },
    back: { type: 'back', label: 'Back', variant: 'outline' },
  })

  .onSuccess(async (values) => {
    await fetch('/api/onboarding', {
      method: 'POST',
      body: JSON.stringify(values),
    });
  })

  .build();

// Render it (see the Components guide for the `components` prop)
<Form config={config} components={uiComponents} />;

// The flow:
// account-type → personal-info → confirm     (if "personal")
// account-type → business-info → confirm     (if "business")