Skip to main content

Multi-Step Forms Without the Wiring Overhead

Breaking a long form into steps sounds simple. In practice it means coordinating step state, per-step validation, back/next logic, conditional steps, and a submit that only fires on the last screen. Teams usually end up with a bespoke state machine that nobody wants to touch.

This guide builds a multi-step onboarding form from scratch — schema first, then a reusable stepper renderer that handles all the navigation.

What we're building​

A three-step onboarding form:

  • Step 1 — Contact: email, phone
  • Step 2 — Company: company name, team size
  • Step 3 — Plan: plan selection, billing email

Validation runs per step before advancing. Submission only fires on step 3.

Schema​

layout.stepper groups fields into named steps. The schema declares structure; navigation lives in the renderer.

onboarding-form.ts
import { buildForm, defineForm, field, layout } from "formwright/schema";

const email = field.email("email", { label: "Email", required: true });
const phone = field.phone("phone", { label: "Phone" });

const companyName = field.text("companyName", { label: "Company name", required: true });
const teamSize = field.select("teamSize", {
label: "Team size",
options: [
{ value: "1-10", label: "1–10" },
{ value: "11-50", label: "11–50" },
{ value: "51-200", label: "51–200" },
{ value: "200+", label: "200+" },
],
});

const plan = field.select("plan", {
label: "Plan",
required: true,
options: [
{ value: "starter", label: "Starter" },
{ value: "pro", label: "Pro" },
{ value: "enterprise", label: "Enterprise" },
],
});

const billingEmail = field.email("billingEmail", { label: "Billing email" });

export const onboardingForm = buildForm({
form: defineForm({ id: "onboarding" }),
fields: [email, phone, companyName, teamSize, plan, billingEmail],
layout: layout.stepper("root", [
{
id: "contact",
label: "Contact",
children: [layout.field(email), layout.field(phone)],
},
{
id: "company",
label: "Company",
children: [layout.field(companyName), layout.field(teamSize)],
},
{
id: "plan",
label: "Plan",
children: [layout.field(plan), layout.field(billingEmail)],
},
]),
});

The stepper renderer​

Register a custom LayoutRendererComponent for the stepper type. It receives all step contents as children and controls which one is visible.

Per-step validation uses RHF's trigger(fieldPaths) — it runs validation for specific fields without submitting.

components/StepperRenderer.tsx
import React, { useState } from "react";
import { useFormContext } from "react-hook-form";
import type { LayoutRendererComponent } from "formwright/react";

// Map step index to the field paths that step owns.
// Keep in sync with the steps array in your schema.
const STEP_FIELDS: string[][] = [
["email", "phone"],
["companyName", "teamSize"],
["plan", "billingEmail"],
];

export const StepperRenderer: LayoutRendererComponent = ({ children, layout }) => {
const { trigger } = useFormContext();
const [currentStep, setCurrentStep] = useState(0);
const steps = (layout as any).steps ?? [];
const childArray = React.Children.toArray(children);
const isFirst = currentStep === 0;
const isLast = currentStep === steps.length - 1;

async function handleNext() {
const valid = await trigger(STEP_FIELDS[currentStep] ?? []);
if (valid) setCurrentStep((s) => s + 1);
}

return (
<div>
{/* Step indicator */}
<nav aria-label="Form steps">
<ol className="flex gap-6 mb-8">
{steps.map((step: { id: string; label: string }, i: number) => (
<li
key={step.id}
aria-current={i === currentStep ? "step" : undefined}
className={i === currentStep ? "font-semibold" : "text-muted-foreground"}
>
<span className="mr-1.5 tabular-nums">{i + 1}.</span>
{step.label}
</li>
))}
</ol>
</nav>

{/* Only render the active step */}
<div>{childArray[currentStep]}</div>

{/* Navigation */}
<div className="flex justify-between mt-8">
<button
type="button"
onClick={() => setCurrentStep((s) => s - 1)}
disabled={isFirst}
className="px-4 py-2 border rounded disabled:opacity-40"
>
Back
</button>

{isLast ? (
<button type="submit" className="px-4 py-2 bg-primary text-primary-foreground rounded">
Submit
</button>
) : (
<button
type="button"
onClick={handleNext}
className="px-4 py-2 bg-primary text-primary-foreground rounded"
>
Next
</button>
)}
</div>
</div>
);
};

trigger(fields) returns false if any field is invalid and sets the error messages automatically. The user sees validation errors without the form advancing.

Render​

Register the renderer via layoutRendererMap. The key "stepper" matches the layout type.

OnboardingPage.tsx
import { FormRuntimeProvider, FormRuntimeRoot, useCreateFormRuntime } from "formwright/react";
import { registerBasicPlugins } from "formwright/plugins";
import { StepperRenderer } from "@/components/StepperRenderer";
import { onboardingForm } from "./onboarding-form";

export function OnboardingPage() {
const runtime = useCreateFormRuntime({
form: onboardingForm,
plugins: registerBasicPlugins(),
});

async function onSubmit(values: Record<string, unknown>) {
await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
}

return (
<FormRuntimeProvider runtime={runtime} onSubmit={onSubmit}>
<FormRuntimeRoot
rootLayoutId="root"
layoutRendererMap={{ stepper: StepperRenderer }}
/>
</FormRuntimeProvider>
);
}

Conditional steps​

Hide an entire step based on a field value. When the step is hidden it's removed from children, so navigation skips it automatically.

layout.stepper("root", [
{ id: "contact", label: "Contact", children: [...] },
{ id: "company", label: "Company", children: [...] },
{
id: "enterprise",
label: "Enterprise details",
children: [...],
visibleWhen: fieldRef(plan).eq("enterprise"),
},
])

Update STEP_FIELDS accordingly, or guard skipped indices with a null check in handleNext.

What changes and what stays the same​

What changes:

  • Step structure lives in the schema, not in component state
  • Navigation and per-step validation live in one renderer registered once
  • Adding or reordering steps means editing the schema, not hunting through JSX

What stays the same:

  • RHF handles field registration, validation, and submission as normal
  • All form values are available in onSubmit in a single flat object
  • Per-step validation uses standard trigger() — nothing magic

See also​