Why useFlow?
Building multi-step flows in React is surprisingly complex. useFlow eliminates that complexity with a declarative, type-safe approach that includes persistence and analytics out of the box.
The hidden complexity of multi-step flows
Section titled “The hidden complexity of multi-step flows”Every React app eventually needs multi-step flows - onboarding, checkout, surveys. They seem simple at first, but quickly become a maintenance nightmare.
The manual approach: a cautionary tale
Section titled “The manual approach: a cautionary tale”Week 1: “This is simple!”
Section titled “Week 1: “This is simple!””function OnboardingFlow() { const [step, setStep] = useState('welcome'); const [formData, setFormData] = useState({});
const handleNext = () => { if (step === 'welcome') setStep('userType'); else if (step === 'userType') setStep('complete'); };
if (step === 'welcome') return <Welcome onNext={handleNext} />; if (step === 'userType') return <UserType onNext={handleNext} />; if (step === 'complete') return <Complete />;}Week 2: “Business vs personal accounts need different steps”
Section titled “Week 2: “Business vs personal accounts need different steps””function OnboardingFlow() { const [step, setStep] = useState('welcome'); const [formData, setFormData] = useState({});
const handleNext = () => { if (step === 'welcome') setStep('userType'); else if (step === 'userType') setStep('complete');
else if (step === 'userType') { if (formData.accountType === 'business') { setStep('business'); } else { setStep('personal'); } } else if (step === 'business') setStep('complete'); else if (step === 'personal') setStep('complete'); // Can you visualize the flow? What are all possible paths? };
if (step === 'welcome') return <Welcome onNext={handleNext} />; if (step === 'userType') return <UserType onNext={handleNext} />; if (step === 'business') return <Business onNext={handleNext} />; if (step === 'personal') return <Personal onNext={handleNext} />; if (step === 'complete') return <Complete />;}Week 3: “We need persistence so users don’t lose progress”
Section titled “Week 3: “We need persistence so users don’t lose progress””function OnboardingFlow() { const [step, setStep] = useState('welcome'); const [formData, setFormData] = useState({}); const [step, setStep] = useState(() => localStorage.getItem('onboarding-step') || 'welcome' ); const [formData, setFormData] = useState(() => JSON.parse(localStorage.getItem('onboarding-data') || '{}') );
useEffect(() => { localStorage.setItem('onboarding-step', step); localStorage.setItem('onboarding-data', JSON.stringify(formData)); }, [step, formData]);
const handleNext = () => { if (step === 'welcome') setStep('userType'); else if (step === 'userType') { if (formData.accountType === 'business') { setStep('business'); } else { setStep('personal'); } } else if (step === 'business') setStep('complete'); else if (step === 'personal') setStep('complete'); };
if (step === 'welcome') return <Welcome onNext={handleNext} />; if (step === 'userType') return <UserType onNext={handleNext} />; if (step === 'business') return <Business onNext={handleNext} />; if (step === 'personal') return <Personal onNext={handleNext} />; if (step === 'complete') return <Complete />;}Week 4: “Users want a back button”
Section titled “Week 4: “Users want a back button””function OnboardingFlow() { const [step, setStep] = useState(() => localStorage.getItem('onboarding-step') || 'welcome' ); const [formData, setFormData] = useState(() => JSON.parse(localStorage.getItem('onboarding-data') || '{}') ); const [stepHistory, setStepHistory] = useState(['welcome']); const [stepHistory, setStepHistory] = useState(() => JSON.parse(localStorage.getItem('onboarding-history') || '["welcome"]') );
useEffect(() => { localStorage.setItem('onboarding-step', step); localStorage.setItem('onboarding-data', JSON.stringify(formData)); }, [step, formData]); localStorage.setItem('onboarding-history', JSON.stringify(stepHistory)); }, [step, formData, stepHistory]);
const handleNext = () => { if (step === 'welcome') setStep('userType'); else if (step === 'userType') { if (formData.accountType === 'business') { setStep('business'); } else { setStep('personal'); } } else if (step === 'business') setStep('complete'); else if (step === 'personal') setStep('complete'); };
const handleBack = () => { const newHistory = stepHistory.slice(0, -1); setStepHistory(newHistory); setStep(newHistory[newHistory.length - 1]); };
if (step === 'welcome') return <Welcome onNext={handleNext} />; if (step === 'userType') return <UserType onNext={handleNext} onBack={handleBack} />; if (step === 'business') return <Business onNext={handleNext} onBack={handleBack} />; if (step === 'personal') return <Personal onNext={handleNext} onBack={handleBack} />; if (step === 'complete') return <Complete />;}Week 5: “We need to track drop-off points”
Section titled “Week 5: “We need to track drop-off points””function OnboardingFlow() { const [step, setStep] = useState(() => localStorage.getItem('onboarding-step') || 'welcome' ); const [formData, setFormData] = useState(() => JSON.parse(localStorage.getItem('onboarding-data') || '{}') ); const [stepHistory, setStepHistory] = useState(['welcome']); const [stepHistory, setStepHistory] = useState(() => JSON.parse(localStorage.getItem('onboarding-history') || '["welcome"]') );
useEffect(() => { localStorage.setItem('onboarding-step', step); localStorage.setItem('onboarding-data', JSON.stringify(formData)); localStorage.setItem('onboarding-history', JSON.stringify(stepHistory)); }, [step, formData, stepHistory]);
const handleNext = () => { let nextStep; if (step === 'welcome') nextStep = 'userType'; else if (step === 'userType') { nextStep = formData.accountType === 'business' ? 'business' : 'personal'; } else if (step === 'business') nextStep = 'complete'; else if (step === 'personal') nextStep = 'complete';
analytics.track('step_completed', { from: step, to: nextStep });
setStepHistory([...stepHistory, nextStep]); setStep(nextStep); };
const handleBack = () => { const newHistory = stepHistory.slice(0, -1); setStepHistory(newHistory); setStep(newHistory[newHistory.length - 1]); };
if (step === 'welcome') return <Welcome onNext={handleNext} />; if (step === 'userType') return <UserType onNext={handleNext} onBack={handleBack} />; if (step === 'business') return <Business onNext={handleNext} onBack={handleBack} />; if (step === 'personal') return <Personal onNext={handleNext} onBack={handleBack} />; if (step === 'complete') return <Complete />;}Week 6: “Add email verification between userType and business”
Section titled “Week 6: “Add email verification between userType and business””// ⚠️ Update handleNext if/else chains to add emailVerify step// ⚠️ Update conditional rendering to add EmailVerify component// Already 50+ lines and growing of just state management and navigation logic...Week 8: “We need a checkout flow now”
Section titled “Week 8: “We need a checkout flow now””// Option 1: Copy-paste all 50+ lines of navigation logic// - Duplicate code to maintain across flows// - Fix a bug? Update it in multiple places
// Option 2: Extract a reusable flow abstraction// - Build state management, persistence, history, analytics// - Congratulations, you just built a less feature-rich version of useFlow and now have to maintain it :)The real cost
Section titled “The real cost”The result: 50+ lines of state management per flow, before you even write your UI. Want 3 flows? That’s 150+ lines of complex logic. No reusability. No type safety. Each flow is unique with its own bugs.
- ❌ No reusability - Each flow is a unique snowflake with its own bugs
- ❌ No type safety - Typo ‘bussiness’ instead of ‘business’? Runtime error
- ❌ Hard to visualize - Flow structure buried in if/else chains
- ❌ Fragile - Add/remove a step? Update 5+ places or risk breaking the flow
- ❌ Time sink - Building flow infrastructure instead of shipping features
The useFlow solution
Section titled “The useFlow solution”More functionality with less complexity:
import { defineFlow, Flow, createLocalStorageStore, createPersister } from '@useflow/react';
type OnboardingContext = { email?: string; accountType?: "business" | "personal"; company?: string;};
// 1. Declarative flow definition - see the entire flow at a glanceconst onboardingFlow = defineFlow({ id: "onboarding", start: "welcome", steps: { welcome: { next: "userType" }, userType: { next: ["business", "personal"] }, // Branching! business: { next: "complete" }, personal: { next: "complete" }, complete: {} }}).with<OnboardingContext>((steps) => ({ resolvers: { // Type-safe: can only return steps in next array userType: (ctx) => ctx.accountType === "business" ? steps.business : steps.personal }}));
// 2. Automatic history and navigation// Add persistence & analytics in 2 lines!function App() { return ( <Flow flow={onboardingFlow} persister={createPersister({ store: createLocalStorageStore() })} onNext={({ from, to }) => analytics.track('step_completed', { from, to })} > {({ renderStep }) => renderStep({ welcome: <WelcomeStep />, userType: <UserTypeStep />, business: <BusinessStep />, personal: <PersonalStep />, complete: <CompleteStep /> })} </Flow> );}That’s it. Persistence, history, analytics, and type-safe navigation built-in. No manual state management. No localStorage code. No navigation logic scattered across components.
What you get out of the box
Section titled “What you get out of the box”Never lose user progress again
Section titled “Never lose user progress again”<Flow flow={myFlow} persister={createPersister({ store: createLocalStorageStore() })} // One line!>Real impact: Users can close their browser, lose connection, or come back days later - they continue exactly where they left off.
Drop-in analytics
Section titled “Drop-in analytics”<Flow flow={myFlow} onNext={({ from, to, context }) => { // Track with ANY analytics platform analytics.track('step_completed', { from, to, ...context }); }} onComplete={({ context, startedAt }) => { analytics.track('flow_completed', { duration: Date.now() - startedAt, ...context }); }}>Real impact: Know exactly where users drop off. See which paths convert best. Make data-driven improvements.
Type safety that actually helps
Section titled “Type safety that actually helps”// TypeScript knows your entire flow structureconst { next } = flow.useFlowState({ step: "profile" });next("preferences"); // ✅ IDE autocompletes valid stepsnext("welcome"); // ❌ Compile error - invalid navigation
// Context is fully typedsetContext({ name: "Alice" }); // ✅ ValidsetContext({ invalid: "field" }); // ❌ TypeScript errorReal impact: Catch navigation bugs at compile time. Refactor with confidence. Onboard new developers faster.
Complete history for debugging
Section titled “Complete history for debugging”const { history } = useFlowState();
// See everything that happenedconsole.log(history);// [// { stepId: "welcome", action: "next", startedAt: 1699564800000, completedAt: 1699564805000 },// { stepId: "userType", action: "back", startedAt: 1699564805000, completedAt: 1699564810000 },// { stepId: "welcome", startedAt: 1699564810000 },// ]Real impact: When users report issues, you can see exactly what happened. No more “unable to reproduce” tickets.
Branching without the complexity
Section titled “Branching without the complexity”defineFlow({ steps: { userType: { next: ["business", "personal"] }, business: { next: "complete" }, personal: { next: "complete" }, complete: {}, }}).with((steps) => ({ resolvers: { userType: (ctx) => ctx.type === "business" ? steps.business : steps.personal, }}));No nested ifs, no complex state machines. Just declare the branches.
When to use useFlow
Section titled “When to use useFlow”Perfect for
Section titled “Perfect for”- Onboarding flows - Welcome → Profile → Preferences → Complete
- Checkout processes - Cart → Shipping → Payment → Confirmation
- Surveys & questionnaires - Dynamic questions with branching
- Multi-step forms - Break complex forms into manageable steps
- Wizards & tutorials - Guide users through features
- Application processes - Loan/job applications with validation
Consider alternatives for
Section titled “Consider alternatives for”- Single forms - If it’s just one form with no steps, a form library alone is enough
- Complex state machines - For intricate non-UI state logic, consider XState
Alternatives
Section titled “Alternatives”Routing Libraries (React Router, TanStack Router)
Section titled “Routing Libraries (React Router, TanStack Router)”Each step as a route sounds good until:
- No shared state without Context/Redux
- No built-in persistence
- Browser back button breaks flow logic
- Still building all navigation logic yourself
Better for: Page navigation, not multi-step processes.
Form Libraries (TanStack Form, React Hook Form)
Section titled “Form Libraries (TanStack Form, React Hook Form)”TanStack Form, React Hook Form, etc. are great for forms, but:
- Don’t handle step navigation
- No progress persistence
- No flow branching logic
Best approach: Use them with useFlow for validation.
Example with TanStack Form:
import { useForm } from '@tanstack/react-form';
function UserTypeStep() { const { context, setContext, next, back } = useFlowState();
const form = useForm({ defaultValues: { email: context.email || "", accountType: context.accountType || "", }, onSubmit: async ({ value }) => { setContext({ email: value.email, accountType: value.accountType, }); next(); // useFlow handles navigation }, });
return ( <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}> {/* TanStack Form handles validation */} <form.Field name="email"> {(field) => ( <input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} /> )} </form.Field> <form.Field name="accountType"> {(field) => ( <select value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} > <option value="">Choose account type</option> <option value="personal">Personal</option> <option value="business">Business</option> </select> )} </form.Field>
{/* useFlow handles multi-step navigation */} <button type="button" onClick={back}>Back</button> <button type="submit">Continue</button> </form> );}- TanStack Form: Handles field validation, form state, submissions
- useFlow: Handles step navigation, flow persistence, shared context
- Together: Complete multi-step form solution with data flowing between steps
XState
Section titled “XState”General state machine library:
- Steep learning curve (actors, events, guards)
- No built-in persistence
- Designed for complex state logic
- Not purpose-built for multi-step UI flows
Better for: Complex state machines with parallel states and intricate business logic.
useFlow gives you everything out of the box: navigation, persistence, branching, TypeScript safety, and it’s under 5KB.
Ready to get started?
Section titled “Ready to get started?”Follow our quick start guide to build your first flow in 5 minutes.
Next steps
Section titled “Next steps”- Quick Start - Build a flow in 5 minutes
- Core Concepts - Understand the architecture
- Examples - See real-world implementations