Headless, type-safe multi-step form library for React — built on react-hook-form and Zod.
| Package | Version | Description |
|---|---|---|
react-formsteps-core |
Headless hooks + context. No UI, no styles. | |
react-formsteps-ui |
Optional pre-built React components. |
- Headless — zero UI imposed. Works with any design system.
- Per-step validation — validates only the current step's Zod schema before advancing.
- Type-safe — strict TypeScript throughout. Types flow from Zod schemas into your fields.
- Built on react-hook-form — full compatibility with the RHF ecosystem.
- Flexible — use the hooks alone or drop in the ready-made components.
- Tiny — tree-shakeable, no CSS bundled.
# npm
npm install react-formsteps-core react-hook-form zod @hookform/resolvers
# pnpm
pnpm add react-formsteps-core react-hook-form zod @hookform/resolvers
# yarn
yarn add react-formsteps-core react-hook-form zod @hookform/resolvers# npm
npm install react-formsteps-core react-formsteps-ui react-hook-form zod @hookform/resolvers
# pnpm
pnpm add react-formsteps-core react-formsteps-ui react-hook-form zod @hookform/resolvers| Dependency | Version |
|---|---|
react |
>=18 |
react-dom |
>=18 |
react-hook-form |
>=7 |
zod |
>=3 |
@hookform/resolvers |
>=3 |
import { useSteps, useStepForm } from 'react-formsteps-core';
import { z } from 'zod';
const schemas = [
z.object({ firstName: z.string().min(1, 'Required'), lastName: z.string().min(1, 'Required') }),
z.object({ email: z.string().email('Enter a valid email') }),
z.object({ password: z.string().min(8, 'Min. 8 characters') }),
];
export function RegistrationForm() {
const { currentStep, next, prev, isFirst, isLast, progress, totalSteps } = useSteps({
totalSteps: schemas.length,
});
const { form, nextWithValidation, isValidating } = useStepForm({
schema: schemas[currentStep],
onNext: (data) => console.log('Step data:', data),
});
const handleNext = async () => {
const ok = await nextWithValidation();
if (ok && !isLast) next();
if (ok && isLast) console.log('All done!', form.getValues());
};
return (
<form>
{/* Progress */}
<div>
Step {currentStep + 1} of {totalSteps} — {progress}%
</div>
<progress value={progress} max={100} />
{/* Step 1 */}
{currentStep === 0 && (
<>
<input {...form.register('firstName')} placeholder="First name" />
<input {...form.register('lastName')} placeholder="Last name" />
</>
)}
{/* Step 2 */}
{currentStep === 1 && <input {...form.register('email')} placeholder="Email" />}
{/* Step 3 */}
{currentStep === 2 && (
<input {...form.register('password')} type="password" placeholder="Password" />
)}
{/* Navigation */}
<button type="button" onClick={prev} disabled={isFirst}>
Back
</button>
<button type="button" onClick={handleNext} disabled={isValidating}>
{isLast ? 'Submit' : 'Next'}
</button>
</form>
);
}import { Steps, Step, StepBar, StepNav } from 'react-formsteps-ui';
import { z } from 'zod';
const schema1 = z.object({ name: z.string().min(1, 'Required') });
const schema2 = z.object({ email: z.string().email() });
const schema3 = z.object({ password: z.string().min(8) });
export function RegistrationForm() {
return (
<Steps
schemas={[schema1, schema2, schema3]}
onSubmit={(data) => console.log('Submitted:', data)}
>
<Step title="Personal info">{/* your fields */}</Step>
<Step title="Contact">{/* your fields */}</Step>
<Step title="Security">{/* your fields */}</Step>
</Steps>
);
}Manages step navigation state.
const {
currentStep, // number — 0-indexed
totalSteps, // number
isFirst, // boolean
isLast, // boolean
next, // () => void
prev, // () => void
goTo, // (index: number) => void
progress, // number — 0 to 100
} = useSteps({ totalSteps: 3, initialStep?: 0, onComplete?: () => void });| Option | Type | Default | Description |
|---|---|---|---|
totalSteps |
number |
— | Required. Must be a positive integer |
initialStep |
number |
0 |
Starting step index |
onComplete |
() => void |
— | Called once when the user first arrives at the last step |
Integrates react-hook-form with per-step Zod validation.
const {
form, // UseFormReturn<z.infer<typeof schema>>
nextWithValidation, // () => Promise<boolean>
isValidating, // boolean
} = useStepForm({ schema, defaultValues?, onNext? });| Option | Type | Description |
|---|---|---|
schema |
ZodType |
Required. Zod schema for the current step |
defaultValues |
Partial<z.infer<TSchema>> |
Initial field values |
onNext |
(data) => void |
Called with validated data when advancing |
Access step state from any component inside a <Steps> or <StepsProvider>.
import { useStepsContext } from 'react-formsteps-core';
const { currentStep, totalSteps, next, prev, goTo, formData } = useStepsContext();Use the context without the UI package.
import { StepsProvider, useStepsContext } from 'react-formsteps-core';
<StepsProvider schemas={[schema1, schema2]} onSubmit={handleSubmit}>
<MyCustomWizard />
</StepsProvider>;import { validateStep, mergeSchemas, validateAllSteps } from 'react-formsteps-core';
// Validate a single step — never throws
const result = await validateStep(schema, data);
// { success: boolean, data?, errors?: Record<string, string> }
// Merge multiple ZodObject schemas into one
const fullSchema = mergeSchemas([step1Schema, step2Schema, step3Schema]);
// Validate all accumulated data against merged schemas
const result = await validateAllSteps(schemas, allData);| Component | Description |
|---|---|
<Steps> |
Root provider. Pass schemas and onSubmit. |
<Step> |
Wrapper for each step's content. Accepts optional title. |
<StepBar> |
Progress bar with optional step labels. |
<StepNav> |
Back / Next / Submit buttons with built-in validation gate. |
All APIs are fully typed. Enable strict: true in your tsconfig.json for the best experience.
{
"compilerOptions": {
"strict": true
}
}Import types directly:
import type {
StepSchema,
StepConfig,
StepsContextValue,
UseStepsOptions,
UseStepsReturn,
UseStepFormOptions,
UseStepFormReturn,
StepsProviderProps,
} from 'react-formsteps-core';Contributions via pull requests are welcome. Please open an issue first to discuss significant changes.
By submitting a contribution you agree that the original author (Franxx) retains full ownership and copyright of the project, including your contributions.
Copyright (c) 2025 Franxx — see LICENSE for full terms.
This software is free for personal, educational, and open source use. Commercial use requires explicit written permission from the author. See the license for details.
Made by Franxx