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

Quick Start

useFlow is a declarative, type-safe library for building multi-step flows in React with built-in persistence. Whether you’re building onboarding, checkout, surveys, or wizards, useFlow eliminates the complexity while providing exceptional developer experience.

This guide will walk you through building a complete onboarding flow from scratch. You’ll learn the fundamentals of useFlow by creating a working application.

A simple 3-step onboarding flow:

  1. Welcome - Greet the user
  2. Profile - Collect name and email
  3. Complete - Show confirmation
  • Node.js 18+ or Bun
  • React 18+
  • Basic knowledge of React hooks
  1. Terminal window
    npm install @useflow/react
  2. Create a new file flow.ts and define your flow structure:

    flow.ts
    import { defineFlow } from "@useflow/react";
    // Define the context type - this is the data that flows through all steps
    type OnboardingContext = {
    name: string;
    email: string;
    };
    // Define your flow with steps and transitions
    export const onboardingFlow = defineFlow({
    id: "onboarding",
    start: "welcome",
    steps: {
    welcome: {
    next: "profile",
    },
    profile: {
    next: "complete",
    },
    complete: {
    // No next property = final step
    },
    },
    });

    Key concepts:

    • id - Unique identifier for this flow (used for persistence)
    • start - The first step to show
    • steps - Object mapping step IDs to their configuration
    • next - Which step comes after this one
  3. Create components for each step. Each component uses the useFlowState() hook to access flow state and navigation.

    WelcomeStep.tsx
    import { useFlowState } from "@useflow/react";
    export function WelcomeStep() {
    const { next } = useFlowState();
    return (
    <div>
    <h1>Welcome to Our App!</h1>
    <p>Let's get you set up in just a few steps.</p>
    <button onClick={() => next()}>Get Started</button>
    </div>
    );
    }
    ProfileStep.tsx
    import { useFlowState } from "@useflow/react";
    import { useState } from "react";
    type OnboardingContext = {
    name: string;
    email: string;
    };
    export function ProfileStep() {
    const { context, next, back, setContext } = useFlowState<OnboardingContext>();
    // Local state for form validation
    const [errors, setErrors] = useState<Record<string, string>>({});
    const handleSubmit = () => {
    const newErrors: Record<string, string> = {};
    if (!context.name) newErrors.name = "Name is required";
    if (!context.email) newErrors.email = "Email is required";
    if (context.email && !context.email.includes("@")) {
    newErrors.email = "Invalid email";
    }
    if (Object.keys(newErrors).length > 0) {
    setErrors(newErrors);
    return;
    }
    next(); // Proceed to next step
    };
    return (
    <div>
    <h1>Create Your Profile</h1>
    <div>
    <label>
    Name
    <input
    type="text"
    value={context.name}
    onChange={(e) => setContext({ name: e.target.value })}
    placeholder="Your name"
    />
    </label>
    {errors.name && <span style={{ color: "red" }}>{errors.name}</span>}
    </div>
    <div>
    <label>
    Email
    <input
    type="email"
    value={context.email}
    onChange={(e) => setContext({ email: e.target.value })}
    placeholder="your@email.com"
    />
    </label>
    {errors.email && <span style={{ color: "red" }}>{errors.email}</span>}
    </div>
    <div>
    <button onClick={() => back()}>Back</button>
    <button onClick={handleSubmit}>Continue</button>
    </div>
    </div>
    );
    }

    What’s happening:

    • context - Access shared flow data
    • setContext() - Update context (partial updates merge automatically)
    • next() - Navigate to the next step
    • back() - Navigate to the previous step
    CompleteStep.tsx
    import { useFlowState } from "@useflow/react";
    type OnboardingContext = {
    name: string;
    email: string;
    };
    export function CompleteStep() {
    const { context } = useFlowState<OnboardingContext>();
    return (
    <div>
    <h1>All Set, {context.name}!</h1>
    <p>We've sent a confirmation to {context.email}</p>
    <button onClick={() => window.location.href = "/dashboard"}>
    Go to Dashboard
    </button>
    </div>
    );
    }
  4. Now put it all together in your main App component:

    App.tsx
    import { Flow } from "@useflow/react";
    import { onboardingFlow } from "./flow";
    import { WelcomeStep } from "./WelcomeStep";
    import { ProfileStep } from "./ProfileStep";
    import { CompleteStep } from "./CompleteStep";
    export function App() {
    return (
    <Flow
    flow={onboardingFlow}
    initialContext={{
    name: "",
    email: "",
    }}
    onComplete={() => {
    console.log("Onboarding completed!");
    }}
    >
    {({ renderStep }) => (
    <div style={{ maxWidth: "600px", margin: "0 auto", padding: "20px" }}>
    {renderStep({
    welcome: <WelcomeStep />,
    profile: <ProfileStep />,
    complete: <CompleteStep />,
    })}
    </div>
    )}
    </Flow>
    );
    }

    Flow Component Props:

    • flow - Your flow definition from defineFlow()
    • initialContext - Starting values for your context
    • onComplete - Callback when the flow finishes
    • children - Render function receiving flow state
  5. Run your app and try the flow:

    1. Click “Get Started” on the welcome screen
    2. Fill in your name and email
    3. See the completion message

    Try these interactions:

    • Click “Back” to return to the welcome step
    • Leave fields empty to see validation
    • Enter an invalid email
App.tsx
import { Flow, createLocalStorageStore, createPersister } from "@useflow/react";
export function App() {
return (
<Flow
flow={onboardingFlow}
// ✅ Add persistence in 1 line!
persister={createPersister({ store: createLocalStorageStore(localStorage) })}
initialContext={{ name: "", email: "" }}
>
{/* 💡 Render your steps here */}
</Flow>
);
}
App.tsx
<Flow
flow={onboardingFlow}
persister={persister}
initialContext={{ name: "", email: "" }}
// ✅ Track with any analytics platform
onNext={({ from, to }) => analytics.track('step_completed', { from, to })}
// ✅ Track completion
onComplete={() => analytics.track('onboarding_completed')}
>
{/* ... */}
</Flow>

✅ How to define a flow with defineFlow()
✅ How to use the useFlowState() hook
✅ How to navigate with next() and back()
✅ How to manage context with setContext()
✅ How to render steps with the Flow component
✅ How to add persistence in 1 line
✅ How to add analytics tracking instantly

Now that you’ve built your first flow, explore more advanced features:

View the complete working code
Complete Example
// flow.ts
import { defineFlow } from "@useflow/react";
type OnboardingContext = {
name: string;
email: string;
};
export const onboardingFlow = defineFlow({
id: "onboarding",
start: "welcome",
steps: {
welcome: { next: "profile" },
profile: { next: "complete" },
complete: {},
},
});
// WelcomeStep.tsx
import { useFlowState } from "@useflow/react";
export function WelcomeStep() {
const { next } = useFlowState();
return (
<div>
<h1>Welcome to Our App!</h1>
<p>Let's get you set up in just a few steps.</p>
<button onClick={() => next()}>Get Started</button>
</div>
);
}
// ProfileStep.tsx
import { useFlowState } from "@useflow/react";
import { useState } from "react";
type OnboardingContext = {
name: string;
email: string;
};
export function ProfileStep() {
const { context, next, back, setContext } = useFlowState<OnboardingContext>();
// Local state for form validation
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = () => {
const newErrors: Record<string, string> = {};
if (!context.name) newErrors.name = "Name is required";
if (!context.email) newErrors.email = "Email is required";
if (context.email && !context.email.includes("@")) {
newErrors.email = "Invalid email";
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
next(); // Proceed to next step
};
return (
<div>
<h1>Create Your Profile</h1>
<div>
<label>
Name
<input
type="text"
value={context.name}
onChange={(e) => setContext({ name: e.target.value })}
placeholder="Your name"
/>
</label>
{errors.name && <span style={{ color: "red" }}>{errors.name}</span>}
</div>
<div>
<label>
Email
<input
type="email"
value={context.email}
onChange={(e) => setContext({ email: e.target.value })}
placeholder="your@email.com"
/>
</label>
{errors.email && <span style={{ color: "red" }}>{errors.email}</span>}
</div>
<div>
<button onClick={() => back()}>Back</button>
<button onClick={handleSubmit}>Continue</button>
</div>
</div>
);
}
// CompleteStep.tsx
import { useFlowState } from "@useflow/react";
type OnboardingContext = {
name: string;
email: string;
};
export function CompleteStep() {
const { context } = useFlowState<OnboardingContext>();
return (
<div>
<h1>All Set, {context.name}!</h1>
<p>We've sent a confirmation to {context.email}</p>
<button onClick={() => window.location.href = "/dashboard"}>
Go to Dashboard
</button>
</div>
);
}
// App.tsx
import { Flow } from "@useflow/react";
import { onboardingFlow } from "./flow";
import { WelcomeStep } from "./WelcomeStep";
import { ProfileStep } from "./ProfileStep";
import { CompleteStep } from "./CompleteStep";
export function App() {
return (
<Flow
flow={onboardingFlow}
initialContext={{
name: "",
email: "",
}}
onComplete={() => {
console.log("Onboarding completed!");
}}
>
{({ renderStep }) => (
<div style={{ maxWidth: "600px", margin: "0 auto", padding: "20px" }}>
{renderStep({
welcome: <WelcomeStep />,
profile: <ProfileStep />,
complete: <CompleteStep />,
})}
</div>
)}
</Flow>
);
}