Every project I've worked on that needed a multi-step form ended up the same way: a tangle of useReducer, scattered localStorage calls, and validation logic duplicated across steps. The fifth time I found myself writing the same "which step are we on?" state machine, I stopped and built something reusable.
That thing is FormFlow — a headless React hook for multi-step forms with conditional branching, Zod validation, and optional persistence baked in.
The problem it solves
Multi-step forms sound simple until you're deep in them:
- Conditional steps — "Show the salary step only if they're applying full-time"
- Partial saves — users abandon halfway through; you need to restore their progress
- Per-step validation — Zod schemas that gate the Next button before advancing
- Back navigation — going back shouldn't wipe the work they already did
All of this is solvable, but it's a lot of boilerplate you rewrite every time.
What FormFlow looks like
import { useFormFlow } from '@shngffrd/formflow'
import { z } from 'zod'
const steps = [
{
id: 'personal',
title: 'Personal Info',
schema: z.object({
name: z.string().min(2),
email: z.string().email(),
}),
},
{
id: 'employment',
title: 'Employment',
schema: z.object({ type: z.enum(['full-time', 'contract']) }),
},
{
id: 'salary',
title: 'Salary',
condition: (data) => data.employment?.type === 'full-time',
schema: z.object({ expected: z.number().min(0) }),
},
]
function ApplicationForm() {
const { state, actions, register } = useFormFlow({ steps, persist: 'application-v1' })
return (
<FormShell state={state} actions={actions}>
<CurrentStep state={state} register={register} />
</FormShell>
)
}
The salary step only appears when the condition returns true — and it recalculates live as the user changes their employment type. No manual if chains. No step index arithmetic.
Key features
Conditional branching — Steps include/exclude themselves based on current form data. The active step list updates automatically.
Zod validation per step — Pass a schema; the hook validates on next() and surfaces field errors. No separate form library needed.
Persistence — Pass a persist key and the hook saves/restores from localStorage. Works across page refreshes, tab closes, everything.
Headless — FormFlow owns zero UI. You bring your own components, design system, whatever. The hook just gives you state + actions.
TypeScript-first — Full generics. Your data object is typed end-to-end based on the schemas you pass.
Installing
npm install @shngffrd/formflow
# or
pnpm add @shngffrd/formflow
Try the live demo
The demo is a realistic software engineer application form — 6 steps, conditional branching, Zod validation, and persistence so you can refresh and pick up where you left off.
Full docs (including the API reference and a getting-started guide) live at:
👉 formflow.shngffrd.com/docs/getting-started
Source
It's fully open source — MIT licensed.
github.com/shngffrddev/formflow
PRs and issues welcome. If you run into something or want a feature, open an issue.
Happy to answer questions in the comments — especially if you've hit the same multi-step form pain and solved it differently. Always curious what patterns people land on.
Top comments (0)