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.
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.
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.
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
onSubmitin a single flat object - Per-step validation uses standard
trigger()— nothing magic
See also
- Conditional Fields —
visibleWhenon layout nodes - Customization —
layoutRendererMapand slot overrides - React API —
FormRuntimeRootprops,LayoutRendererComponent