Skip to content
⚠️ Beta: API may change before v1.0. Pin to ~0.x.0 to avoid breaking changes.

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.

Every React app eventually needs multi-step flows - onboarding, checkout, surveys. They seem simple at first, but quickly become a maintenance nightmare.

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 />;
}
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...
// 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 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

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 glance
const 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.

<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.

<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.

// TypeScript knows your entire flow structure
const { next } = flow.useFlowState({ step: "profile" });
next("preferences"); // ✅ IDE autocompletes valid steps
next("welcome"); // ❌ Compile error - invalid navigation
// Context is fully typed
setContext({ name: "Alice" }); // ✅ Valid
setContext({ invalid: "field" }); // ❌ TypeScript error

Real impact: Catch navigation bugs at compile time. Refactor with confidence. Onboard new developers faster.

const { history } = useFlowState();
// See everything that happened
console.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.

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.

  • 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
  • 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

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

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.

Follow our quick start guide to build your first flow in 5 minutes.