Multi-step form wizard library for React - composable, type-safe, and built on react-hook-form.
Single-page forms become hard to manage once they branch on user choices, require async field checks, or span more than a screen's worth of inputs. @itiana/form-architect gives you a structured wizard model - steps, per-step validation, conditional field rendering, and debounced async checks - without replacing react-hook-form's validation engine. Each step validates only its own registered fields before advancing, so users see errors at the right moment rather than all at once on submit.
- Multi-step onboarding or registration wizards
- Checkout flows (shipping → payment → review)
- Branching surveys where later questions depend on earlier answers
- Configuration UIs where async checks (username availability, coupon codes) gate step advancement
- Any flow where you want validated forward progress and the ability to go back
- Single-step forms - react-hook-form alone is simpler
- Forms that run as React Server Actions (RSC / Next.js server-side submit)
- Dynamic field arrays (
useFieldArray) - this library has no built-in field array support - UIs that need to control the active step index from outside the wizard
| Dependency | Tested version | Notes |
|---|---|---|
| React | 18.x | React 19 not verified |
| react-hook-form | ^7.0.0 | Peer dependency |
| TypeScript | >=5.4 | Strict mode required |
| Vite | 5.x | Library mode build, vite-plugin-dts |
npm install @itiana/form-architect react-hook-formPeer dependencies: react ^18, react-dom ^18, react-hook-form ^7.
import { FormWizard, FormStep, ConditionalField } from '@itiana/form-architect';
import type { StepConfig } from '@itiana/form-architect';
interface CheckoutData {
email: string;
paymentMethod: 'card' | 'paypal';
cardNumber?: string;
}
const steps: StepConfig<CheckoutData>[] = [
{ id: 'contact', title: 'Contact', fields: ['email'] },
{ id: 'payment', title: 'Payment', fields: ['paymentMethod', 'cardNumber'] },
];
export function CheckoutWizard() {
return (
<FormWizard<CheckoutData>
steps={steps}
defaultValues={{ email: '', paymentMethod: 'card' }}
onSubmit={(data) => submitOrder(data)}
>
{({ currentStep, wizardState, next, previous, form }) => (
<>
{currentStep.id === 'contact' && (
<FormStep title="Contact">
<input {...form.register('email', { required: 'Email is required' })} />
</FormStep>
)}
{currentStep.id === 'payment' && (
<FormStep title="Payment">
<select {...form.register('paymentMethod')}>
<option value="card">Card</option>
<option value="paypal">PayPal</option>
</select>
<ConditionalField
condition={{ watchField: 'paymentMethod', operator: 'eq', value: 'card' }}
unregisterOnHide
>
<input {...form.register('cardNumber', { required: 'Card number required' })} />
</ConditionalField>
</FormStep>
)}
<div>
{!wizardState.isFirstStep && <button type="button" onClick={previous}>Back</button>}
{!wizardState.isLastStep
? <button type="button" onClick={() => next()}>Next</button>
: <button type="submit">Place order</button>}
</div>
</>
)}
</FormWizard>
);
}FormWizard is a thin shell: it delegates all state to useFormWizard, which owns the step index and wraps react-hook-form. Components like FormStep and ConditionalField are structural primitives - they impose no validation logic of their own. The library sits entirely above react-hook-form's API surface, so every RHF feature (rules, context, useWatch, useFormContext) remains available inside the render prop.
When next() is called, the wizard triggers trigger() on only the fields listed in the current step's fields array. If validation fails, the step does not advance and errors surface normally through formState.errors. On final submit, handleSubmit runs a full-form validation pass before calling onSubmit.
Steps with an empty fields array (e.g. a review step) advance without triggering any validation.
Hidden Field Policy
ConditionalField accepts an unregisterOnHide boolean prop. When true, fields inside a hidden ConditionalField are unregistered from react-hook-form when they disappear, clearing their values and errors. When false (the default), values are preserved in the form state even while the field is hidden - useful when you want hidden values to survive a back-and-forward navigation without resetting.
| Method | Description |
|---|---|
next(options?) |
Validate current step fields then advance. Returns Promise<boolean>. |
previous() |
Move back one step without validation. |
goTo(index, options?) |
Jump to any step. Optionally validate the current step first. |
reset() |
Reset form values to defaultValues and return to step 0. |
Root component. Provides a FormProvider context and renders a <form> element. Uses a render prop to expose the full wizard context.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
steps |
StepConfig<T>[] |
yes | - | Ordered step definitions |
defaultValues |
DefaultValues<T> |
no | - | Initial form values passed to react-hook-form |
onSubmit |
(data: T) => void | Promise<void> |
yes | - | Called after final-step validation passes |
children |
(ctx: UseFormWizardReturn<T>) => ReactNode |
yes | - | Render prop receiving the full wizard context |
className |
string |
no | - | CSS class applied to the <form> element |
formOptions |
Omit<UseFormProps<T>, 'defaultValues'> |
no | - | Extra options forwarded to useForm (e.g. mode) |
onStepChange |
(from: number, to: number) => void |
no | - | Fired after each successful step transition |
Semantic section wrapper. Renders an <section> with an optional <h2> heading and <p> description. Accepts all standard HTMLAttributes<HTMLElement>.
| Prop | Type | Required | Description |
|---|---|---|---|
title |
string |
no | Rendered as a heading element |
description |
string |
no | Rendered as a paragraph |
children |
ReactNode |
yes | Form fields |
Renders children only when the condition(s) evaluate to true against live watched field values.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
condition |
FieldCondition | FieldCondition[] |
yes | - | One or more field conditions to evaluate |
allOf |
boolean |
no | false |
When true, all conditions must pass. When false, any one suffices. |
children |
ReactNode |
yes | - | Content shown when condition is met |
fallback |
ReactNode |
no | - | Content shown when condition is not met |
unregisterOnHide |
boolean |
no | false |
Unregister fields from RHF when hidden, clearing their values |
Supported operators: eq, neq, gt, gte, lt, lte, includes, truthy, falsy.
Low-level hook for building custom wizard UIs without the FormWizard component. Returns UseFormWizardReturn<T>.
const { form, wizardState, currentStep, steps, next, previous, goTo, reset, handleSubmit } =
useFormWizard<MyForm>({
steps,
defaultValues: { email: '' },
formOptions: { mode: 'onBlur' },
onStepChange: (from, to) => console.info(from, to),
});UseFormWizardOptions<T> fields:
| Field | Type | Required | Description |
|---|---|---|---|
steps |
StepConfig<T>[] |
yes | Ordered step definitions |
defaultValues |
DefaultValues<T> |
no | Initial form values |
formOptions |
Omit<UseFormProps<T>, 'defaultValues'> |
no | Extra options forwarded to useForm |
onStepChange |
(from: number, to: number) => void |
no | Callback fired on each successful step transition |
UseFormWizardReturn<T> fields:
| Field | Type | Description |
|---|---|---|
form |
UseFormReturn<T> |
Full react-hook-form instance |
wizardState |
WizardState |
Current step index, progress, flags |
steps |
StepConfig<T>[] |
The step definitions array |
currentStep |
StepConfig<T> |
Active step definition |
next |
(options?: WizardNavigationOptions) => Promise<boolean> |
Validate and advance |
previous |
() => void |
Move back without validation |
goTo |
(index: number, options?: GoToOptions) => Promise<boolean> |
Jump to any step index |
reset |
() => void |
Reset form and return to step 0 |
handleSubmit |
(onValid: (data: T) => void | Promise<void>) => (e?) => Promise<void> |
Wrapped RHF submit handler |
Debounced async validator with AbortController cancellation. Safe to call on every keystroke.
async function checkUsername(value: string, signal: AbortSignal): Promise<true | string> {
const res = await fetch(`/api/check-username?q=${value}`, { signal });
const { available } = await res.json() as { available: boolean };
return available || 'Username is already taken';
}
const { validate, state } = useAsyncValidation(checkUsername, 400);
// Inside register:
form.register('username', { validate: (v) => validate(v) });
// Render state:
{state.isPending && <span aria-live="polite">Checking availability...</span>}
{state.error && <span role="alert">{state.error}</span>}AsyncValidationState fields:
| Field | Type | Description |
|---|---|---|
isPending |
boolean |
true while a debounced check is in flight |
result |
AsyncValidationResult | null |
Last completed result (valid / invalid / cancelled) |
error |
string | null |
Shortcut for the message when result is invalid |
isValid |
boolean | null |
true / false after resolution, null while idle |
AsyncValidationResult discriminated union:
type AsyncValidationResult =
| { status: 'valid' }
| { status: 'invalid'; message: string }
| { status: 'cancelled' };FormWizard and FormStep are structural primitives - they render a <form> and <section> respectively but make no assumptions about your heading hierarchy, ARIA roles, or live regions. Consumers are responsible for:
- Associating
<label htmlFor>with every inputid - Adding
aria-describedbyto inputs when an error is present - Using
role="alert"oraria-liveon error messages - Providing a visible progress indicator (step count, progress bar, etc.)
- Managing focus after step transitions if needed for keyboard/screen reader users
This approach avoids opinionated ARIA structures that conflict with the host application's landmark regions.
@itiana/form-architect is a client-side library. It uses browser APIs (AbortController) and react-hook-form's client-side hooks. It does not support React Server Components.
Next.js (App Router): add 'use client' to any file that imports from this package.
'use client';
import { FormWizard } from '@itiana/form-architect';Remix / Vite SSR: ensure this package is not imported in server-executed code paths.
- React 19 - not verified. The library targets React 18.x.
- Field arrays -
useFieldArrayis not integrated. Dynamic lists of fields require a custom solution. - Server Actions -
onSubmitis a client callback. It cannot be a Next.js Server Action directly; wrap server calls insideonSubmitinstead. - Controlled step index - the active step index is managed internally. External state cannot drive it; use
goTofor programmatic navigation. - No drag-and-drop step reordering - step order is fixed at mount from the
stepsprop.
npm install # install all dependencies
npm run typecheck # tsc --noEmit (strict mode)
npm test # vitest run (all tests)
npm run test:watch # vitest watch mode
npm run lint # eslint src __tests__
npm run build # vite library build → dist/0.1.0 - API is stabilising but not yet frozen. Minor versions may include breaking changes until 1.0.0.
MIT - see LICENSE