Multi-Step Form Navigation
Using the FormNavigation component from @reformer/ui for multi-step form wizard with step-by-step validation.
Overviewβ
In multi-step forms we need to:
- Validate only the current step β don't show errors for fields on future steps
- Preserve full validation β for final submission
- Track completed steps β allow navigation only to visited steps
- Provide navigation methods β Next, Back, Go to step
The @reformer/ui package provides FormNavigation β a headless compound component that handles all this logic.
Installationβ
npm install @reformer/ui
The Problemβ
When registering validation at form creation:
createForm<CreditApplicationForm>({
schema: creditApplicationSchema,
behavior: creditApplicationBehaviors,
validation: creditApplicationValidation, // Full validation
});
Calling form.validate() validates all fields, including those on steps the user hasn't reached yet.
We need a way to validate only specific fields on each step while preserving full validation for final submission.
Solution: FormNavigationβ
FormNavigation from @reformer/ui provides:
- Step-by-step validation via
validateForminternally - Progress tracking with completed steps
- Headless compound components for flexible UI
- Ref handle for programmatic navigation
Step Configurationβ
First, define validation schemas for each step:
export { loanValidation } from './loan-info/validators';
export { personalValidation } from './personal-info/validators';
export { contactValidation } from './contact-info/validators';
export { employmentValidation } from './employment/validators';
export { additionalValidation } from './additional-info/validators';
Then define step metadata:
const STEPS = [
{ number: 1, title: 'Loan', icon: 'π°' },
{ number: 2, title: 'Personal Data', icon: 'π€' },
{ number: 3, title: 'Contacts', icon: 'π' },
{ number: 4, title: 'Employment', icon: 'πΌ' },
{ number: 5, title: 'Additional', icon: 'π' },
{ number: 6, title: 'Confirmation', icon: 'β
' },
];
const STEP_VALIDATIONS = {
1: loanValidation,
2: personalValidation,
3: contactValidation,
4: employmentValidation,
5: additionalValidation,
// Step 6 β confirmation, no validation needed
};
Using FormNavigationβ
Basic Structureβ
import { useMemo, useRef } from 'react';
import { createForm } from '@reformer/core';
import { FormNavigation, type FormNavigationHandle } from '@reformer/ui/form-navigation';
// Step components
import { BasicInfoForm } from './steps/loan-info/BasicInfoForm';
import { PersonalInfoForm } from './steps/personal-info/PersonalInfoForm';
import { ContactInfoForm } from './steps/contact-info/ContactInfoForm';
import { EmploymentForm } from './steps/employment/EmploymentForm';
import { AdditionalInfoForm } from './steps/additional-info/AdditionalInfoForm';
import { ConfirmationForm } from './steps/confirmation/ConfirmationForm';
// Validators
import { creditApplicationValidation } from './validators';
function CreditApplicationForm() {
const navRef = useRef<FormNavigationHandle<CreditApplicationFormType>>(null);
const form = useMemo(
() =>
createForm<CreditApplicationFormType>({
form: creditApplicationSchema,
behavior: creditApplicationBehaviors,
validation: creditApplicationValidation,
}),
[]
);
// Navigation configuration
const navConfig = useMemo(
() => ({
stepValidations: STEP_VALIDATIONS,
fullValidation: creditApplicationValidation,
}),
[]
);
const handleSubmit = async () => {
const result = await navRef.current?.submit(async (values) => {
const response = await saveApplication(values);
return response;
});
if (result) {
alert('Application submitted!');
}
};
return (
<FormNavigation ref={navRef} form={form} config={navConfig}>
{/* Compound components here */}
</FormNavigation>
);
}
FormNavigation.Indicatorβ
Headless step indicator with render props:
<FormNavigation.Indicator steps={STEPS}>
{({ steps, goToStep }) => (
<div className="flex justify-between mb-4">
{steps.map((step) => (
<button
key={step.number}
onClick={() => step.canNavigate && goToStep(step.number)}
disabled={!step.canNavigate}
className={`px-4 py-2 rounded ${
step.isCurrent
? 'bg-blue-600 text-white'
: step.isCompleted
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-400'
}`}
>
{step.isCompleted ? 'β' : step.icon} {step.title}
</button>
))}
</div>
)}
</FormNavigation.Indicator>
Render props:
| Property | Type | Description |
|---|---|---|
steps | StepWithState[] | Steps with computed state |
goToStep | (step: number) => boolean | Navigate to step |
currentStep | number | Current step number |
totalSteps | number | Total number of steps |
Step state:
| Property | Type | Description |
|---|---|---|
number | number | Step number (from 1) |
title | string | Step title |
icon | string? | Icon (optional) |
isCurrent | boolean | Is current step |
isCompleted | boolean | Is completed |
canNavigate | boolean | Can navigate to this step |
FormNavigation.Stepβ
Renders component when step is active:
<div className="bg-white p-8 rounded-lg shadow-md">
<FormNavigation.Step component={BasicInfoForm} control={form} />
<FormNavigation.Step component={PersonalInfoForm} control={form} />
<FormNavigation.Step component={ContactInfoForm} control={form} />
<FormNavigation.Step component={EmploymentForm} control={form} />
<FormNavigation.Step component={AdditionalInfoForm} control={form} />
<FormNavigation.Step component={ConfirmationForm} control={form} />
</div>
Steps render in order β first Step is step 1, second is step 2, etc. Only the current step is displayed.
FormNavigation.Actionsβ
Headless navigation buttons with render props:
<FormNavigation.Actions onSubmit={handleSubmit}>
{({ prev, next, submit, isFirstStep, isLastStep, isValidating }) => (
<div className="flex justify-between mt-6">
<Button
onClick={prev.onClick}
disabled={isFirstStep || prev.disabled}
variant="secondary"
>
Back
</Button>
{!isLastStep ? (
<Button onClick={next.onClick} disabled={next.disabled}>
{isValidating ? 'Validating...' : 'Next'}
</Button>
) : (
<Button onClick={submit.onClick} disabled={submit.disabled}>
{submit.isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
)}
</div>
)}
</FormNavigation.Actions>
Render props:
| Property | Type | Description |
|---|---|---|
prev | ButtonProps | "Back" button props |
next | ButtonProps | "Next" button props |
submit | SubmitButtonProps | "Submit" button props |
isFirstStep | boolean | On first step |
isLastStep | boolean | On last step |
isValidating | boolean | Validation in progress |
FormNavigation.Progressβ
Headless progress display:
<FormNavigation.Progress>
{({ current, total, percent }) => (
<div className="mt-4 text-center text-sm text-gray-600">
Step {current} of {total} β’ {percent}% complete
</div>
)}
</FormNavigation.Progress>
Full Exampleβ
import { useMemo, useRef } from 'react';
import { createForm } from '@reformer/core';
import { FormNavigation, type FormNavigationHandle } from '@reformer/ui/form-navigation';
import { Button } from '@/components/ui/button';
// Step and validator imports...
const STEPS = [
{ number: 1, title: 'Loan', icon: 'π°' },
{ number: 2, title: 'Personal Data', icon: 'π€' },
{ number: 3, title: 'Contacts', icon: 'π' },
{ number: 4, title: 'Employment', icon: 'πΌ' },
{ number: 5, title: 'Additional', icon: 'π' },
{ number: 6, title: 'Confirmation', icon: 'β
' },
];
const STEP_VALIDATIONS = {
1: loanValidation,
2: personalValidation,
3: contactValidation,
4: employmentValidation,
5: additionalValidation,
};
function CreditApplicationForm() {
const navRef = useRef<FormNavigationHandle<CreditApplicationFormType>>(null);
const form = useMemo(
() =>
createForm<CreditApplicationFormType>({
form: creditApplicationSchema,
behavior: creditApplicationBehaviors,
validation: creditApplicationValidation,
}),
[]
);
const navConfig = useMemo(
() => ({
stepValidations: STEP_VALIDATIONS,
fullValidation: creditApplicationValidation,
}),
[]
);
const submitApplication = async () => {
const result = await navRef.current?.submit(async (values) => {
const response = await saveApplication(values);
alert(`Application submitted! ID: ${response.id}`);
return response;
});
if (!result) {
alert('Please fix the errors in the form');
}
};
return (
<FormNavigation ref={navRef} form={form} config={navConfig}>
{/* Step indicator */}
<FormNavigation.Indicator steps={STEPS}>
{({ steps, goToStep }) => (
<div className="flex justify-between mb-4">
{steps.map((step) => (
<button
key={step.number}
onClick={() => step.canNavigate && goToStep(step.number)}
disabled={!step.canNavigate}
className={`px-4 py-2 rounded transition-colors ${
step.isCurrent
? 'bg-blue-600 text-white'
: step.isCompleted
? 'bg-green-100 text-green-800 hover:bg-green-200'
: step.canNavigate
? 'bg-gray-200 text-gray-700 hover:bg-gray-300'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
{step.isCompleted ? 'β' : step.icon} {step.title}
</button>
))}
</div>
)}
</FormNavigation.Indicator>
{/* Step content */}
<div className="bg-white p-8 rounded-lg shadow-md">
<FormNavigation.Step component={BasicInfoForm} control={form} />
<FormNavigation.Step component={PersonalInfoForm} control={form} />
<FormNavigation.Step component={ContactInfoForm} control={form} />
<FormNavigation.Step component={EmploymentForm} control={form} />
<FormNavigation.Step component={AdditionalInfoForm} control={form} />
<FormNavigation.Step component={ConfirmationForm} control={form} />
</div>
{/* Navigation buttons */}
<FormNavigation.Actions onSubmit={submitApplication}>
{({ prev, next, submit, isFirstStep, isLastStep, isValidating }) => (
<div className="flex justify-between mt-6">
<Button
onClick={prev.onClick}
disabled={isFirstStep || prev.disabled}
variant="secondary"
>
Back
</Button>
{!isLastStep ? (
<Button onClick={next.onClick} disabled={next.disabled}>
{isValidating ? 'Validating...' : 'Next'}
</Button>
) : (
<Button onClick={submit.onClick} disabled={submit.disabled}>
{submit.isSubmitting ? 'Submitting...' : 'Submit Application'}
</Button>
)}
</div>
)}
</FormNavigation.Actions>
{/* Progress */}
<FormNavigation.Progress>
{({ current, total, percent }) => (
<div className="mt-4 text-center text-sm text-gray-600">
Step {current} of {total} β’ {percent}% complete
</div>
)}
</FormNavigation.Progress>
</FormNavigation>
);
}
export default CreditApplicationForm;
Programmatic Navigationβ
Use the ref handle for external control:
const navRef = useRef<FormNavigationHandle<MyForm>>(null);
// Programmatic navigation
navRef.current?.goToStep(2);
navRef.current?.goToNextStep();
navRef.current?.goToPreviousStep();
// Submit with validation
const result = await navRef.current?.submit(async (values) => {
return api.submit(values);
});
FormNavigationHandle APIβ
| Property/Method | Type | Description |
|---|---|---|
currentStep | number | Current step (from 1) |
completedSteps | number[] | Completed steps |
isFirstStep | boolean | On first step |
isLastStep | boolean | On last step |
isValidating | boolean | Validation in progress |
goToNextStep() | Promise<boolean> | Validate and go next |
goToPreviousStep() | void | Go back |
goToStep(step) | boolean | Go to step |
submit(onSubmit) | Promise<R | null> | Full validation and submit |
Key Benefitsβ
- Headless β full UI control, any styles
- Compound Components β declarative, composable API
- Render Props β access to all state for custom rendering
- Type Safety β full TypeScript support with generics
- Reusability β works with any form with step-by-step validation
What's Next?β
Now that navigation is ready, the following sections cover:
- Working with Data β loading, saving, and resetting form data
- Submission β handling form submission, errors, and retries