Flows
A flow is the heart of useFlow. It’s a declarative, JSON-serializable definition of your multi-step process that defines the structure, steps, and transitions.
Flow anatomy
Section titled “Flow anatomy”Every flow has three required properties:
import { defineFlow } from '@useflow/react';
const myFlow = defineFlow({ id: 'onboarding', // Unique identifier for persistence start: 'welcome', // Starting step steps: { // Step definitions welcome: { next: 'profile' }, profile: { next: 'complete' }, complete: {} // Terminal step (no next) }});Required properties
Section titled “Required properties”| Property | Type | Description |
|---|---|---|
id | string | Unique identifier for this flow (used for persistence) |
start | string | The step ID where the flow begins |
steps | Record<string, StepDefinition> | Map of step IDs to step definitions |
Optional properties
Section titled “Optional properties”| Property | Type | Description |
|---|---|---|
version | string | Schema version for migrations (e.g., "v1", "2024-01") |
variantId | string | Variant identifier for flow variations (e.g., "express", "full") |
Step transitions
Section titled “Step transitions”Each step defines where users can go next using the next property:
Linear transitions
Section titled “Linear transitions”Single destination - users automatically proceed to the specified step:
steps: { welcome: { next: 'profile' // Always go to 'profile' }, profile: { next: 'complete' }, complete: {} // No next - terminal step}Branching transitions
Section titled “Branching transitions”Multiple possible destinations - requires either a resolver function or component-driven navigation:
steps: { userType: { next: ['business', 'personal'] // Can go to either step }, business: { next: 'complete' }, personal: { next: 'complete' }, complete: {}}There are two ways to handle branching:
1. Context-Driven navigation (with Resolvers)
Section titled “1. Context-Driven navigation (with Resolvers)”Flow automatically decides based on context:
type SignupContext = { userType: 'business' | 'personal';};
const flow = defineFlow({ id: 'signup', start: 'userType', steps: { userType: { next: ['business', 'personal'] }, business: { next: 'complete' }, personal: { next: 'complete' }, complete: {} }}).with<SignupContext>((steps) => ({ resolvers: { userType: (ctx) => ctx.userType === 'business' ? steps.business : steps.personal }}));2. Component-Driven navigation (Explicit Navigation)
Section titled “2. Component-Driven navigation (Explicit Navigation)”Component explicitly chooses the destination:
function UserTypeStep() { const { next } = flow.useFlowState({ step: 'userType' });
return ( <div> <button onClick={() => next('business')}>Business</button> <button onClick={() => next('personal')}>Personal</button> </div> );}Terminal steps
Section titled “Terminal steps”Steps with no next property are terminal - reaching them completes the flow:
steps: { welcome: { next: 'complete' }, complete: {} // Terminal - flow is complete}Type safety
Section titled “Type safety”useFlow infers types from your flow definition, giving you:
Type-safe step names
Section titled “Type-safe step names”const flow = defineFlow({ id: 'onboarding', start: 'welcome', steps: { welcome: { next: 'profile' }, profile: { next: 'complete' }, complete: {} }});
// ✅ Validflow.useFlowState({ step: 'welcome' });
// ❌ TypeScript error: "invalid" is not a valid stepflow.useFlowState({ step: 'invalid' });Type-safe navigation
Section titled “Type-safe navigation”function WelcomeStep() { const { next } = flow.useFlowState({ step: 'welcome' });
// ✅ Valid - 'profile' is the only valid next step next();
// ❌ TypeScript error - can't navigate to 'complete' from 'welcome' next('complete');}Type-safe context
Section titled “Type-safe context”type OnboardingContext = { name: string; email: string;};
const flow = defineFlow({ id: 'onboarding', start: 'welcome', steps: { welcome: { next: 'complete' }, complete: {} }});
function WelcomeStep() { const { next, context } = flow.useFlowState({ step: 'welcome' });
// ✅ context is typed as OnboardingContext console.log(context.name);
// ✅ Type-safe updates next({ name: 'Alice', email: 'alice@example.com' });
// ❌ TypeScript error - invalid property next({ invalidProp: 'value' });}Schema versioning
Section titled “Schema versioning”Use version to handle breaking changes in your flow structure:
const flow = defineFlow({ id: 'onboarding', version: 'v2', // Schema version start: 'welcome', steps: { welcome: { next: 'profile' }, profile: { next: 'preferences' }, // New in v2 preferences: { next: 'complete' }, complete: {} }});When you update your flow structure, increment the version and provide a migration function to transform old saved state:
const flow = defineFlow({ id: 'onboarding', version: 'v2', start: 'welcome', steps: { /* ... */ }}).with<OnboardingContext>(() => ({ migration: (oldState, oldVersion) => { if (oldVersion === 'v1') { // Transform v1 state to v2 return { ...oldState, // Add default for new field preferences: { theme: 'light', notifications: true } }; } return oldState; }}));Flow variants
Section titled “Flow variants”Use variantId to run different variations of the same flow (for A/B testing, feature flags, or user segments):
// Standard onboardingconst standardFlow = defineFlow({ id: 'onboarding', variantId: 'standard', start: 'welcome', steps: { welcome: { next: 'profile' }, profile: { next: 'preferences' }, preferences: { next: 'complete' }, complete: {} }});
// Express onboarding (fewer steps)const expressFlow = defineFlow({ id: 'onboarding', variantId: 'express', start: 'welcome', steps: { welcome: { next: 'complete' }, complete: {} }});Variants enable A/B testing, feature flags, role-based flows, and server-driven flows.
Learn more about flow variants →
Flow instances
Section titled “Flow instances”When the same flow definition is used multiple times (e.g., multiple tasks, orders, or documents), use instanceId to maintain separate state for each:
// Same flow, different instances with separate statefunction TaskList({ tasks }) { return ( <> {tasks.map(task => ( <Flow key={task.id} flow={taskFlow} instanceId={task.id} // Each task has its own state persister={persister} > {({ renderStep }) => renderStep({ /* ... */ })} </Flow> ))} </> );}Common use cases for instance IDs
Section titled “Common use cases for instance IDs”Multiple items of same type:
// Each order has separate checkout flow state<Flow flow={checkoutFlow} instanceId={`order-${orderId}`} />
// Each document has separate review flow state<Flow flow={reviewFlow} instanceId={`doc-${documentId}`} />
// Each user profile edit has separate state<Flow flow={editProfileFlow} instanceId={`user-${userId}`} />Reusable flows:
// Generic task creation flow used for different task types<Flow flow={createTaskFlow} instanceId={`${taskType}-${timestamp}`} />
// Survey flow reused for different products<Flow flow={surveyFlow} instanceId={`survey-${productId}`} />How instance IDs work
Section titled “How instance IDs work”Instance IDs affect:
- Persistence keys - State is saved under
[prefix]:[flowId]:[variantId]:[instanceId] - State isolation - Each instance maintains completely separate state
- Concurrent flows - Multiple instances can run simultaneously without interference
// Without instanceId - all use same storage key<Flow flow={taskFlow} /> // Key: "flow:task::"<Flow flow={taskFlow} /> // Key: "flow:task::" (overwrites!)
// With instanceId - each has unique storage<Flow flow={taskFlow} instanceId="task1" /> // Key: "flow:task::task1"<Flow flow={taskFlow} instanceId="task2" /> // Key: "flow:task::task2"Remote flows
Section titled “Remote flows”Flows are JSON-serializable - they can be fetched from an API:
// Fetch flow definition from your backendconst flowDefinition = await fetch('/api/flows/onboarding').then(r => r.json());
const flow = defineFlow(flowDefinition);
// Use it like any other flow<Flow flow={flow} initialContext={{}}> {({ renderStep }) => renderStep({ welcome: <WelcomeStep />, profile: <ProfileStep />, complete: <CompleteStep /> })}</Flow>This enables:
- Dynamic flows: Change flow structure without deploying code
- Personalization: Serve different flows to different users
- A/B testing: Test flow variations server-side
- Feature flags: Enable/disable steps remotely
Flow definition vs runtime flow
Section titled “Flow definition vs runtime flow”useFlow distinguishes between:
- Flow Definition (
FlowDefinition): The declarative structure (JSON-serializable) - Runtime Flow (
RuntimeFlowDefinition): The definition + runtime config (resolvers, migrations)
// Definition (JSON-serializable)const definition = { id: 'onboarding', start: 'welcome', steps: { welcome: { next: 'complete' }, complete: {} }};
// Runtime flow (definition + config)const flow = defineFlow(definition).with<MyContext>((steps) => ({ resolvers: { /* ... */ }, migration: (state, version) => { /* ... */ }}));This separation means:
- ✅ Flow structures can be fetched remotely
- ✅ Runtime logic (resolvers, migrations) stays in your code
- ✅ Type safety is preserved
- ✅ Flows are cacheable and portable
Complete example
Section titled “Complete example”Here’s a complete flow with all features:
import { defineFlow } from '@useflow/react';
type OnboardingContext = { name: string; email: string; userType?: 'business' | 'personal'; companyName?: string; preferences?: { theme: 'light' | 'dark'; notifications: boolean; };};
export const onboardingFlow = defineFlow({ id: 'onboarding', version: 'v2', variantId: 'standard', start: 'welcome', steps: { welcome: { next: 'profile' }, profile: { next: 'userType' }, userType: { // Context-driven navigation next: ['businessDetails', 'preferences'] }, businessDetails: { next: 'preferences' }, preferences: { next: 'complete' }, complete: {} }}).with<OnboardingContext>((steps) => ({ // Resolver for context-driven navigation resolvers: { userType: (ctx) => ctx.userType === 'business' ? steps.businessDetails : steps.preferences },
// Migration from v1 to v2 migration: (state, version) => { if (version === 'v1') { return { ...state, preferences: { theme: 'light', notifications: true } }; } return state; }}));Best practices
Section titled “Best practices”1. Keep steps granular
Section titled “1. Keep steps granular”Break complex processes into small, focused steps:
// ❌ Avoid: One giant stepsteps: { onboarding: { next: 'complete' }, complete: {}}
// ✅ Better: Multiple focused stepssteps: { welcome: { next: 'profile' }, profile: { next: 'preferences' }, preferences: { next: 'complete' }, complete: {}}2. Use semantic step names
Section titled “2. Use semantic step names”Choose clear, descriptive names:
// ❌ Avoid: Generic namessteps: { step1: { next: 'step2' }, step2: { next: 'step3' }}
// ✅ Better: Semantic namessteps: { welcome: { next: 'profile' }, profile: { next: 'preferences' }, preferences: { next: 'complete' }}3. Use versions for breaking changes
Section titled “3. Use versions for breaking changes”Increment version when structure changes:
const flow = defineFlow({ id: 'onboarding', version: 'v2', // Incremented from v1 // ... provide migration function});4. Use variants for variations
Section titled “4. Use variants for variations”Keep same flow ID, different variant IDs:
// Same flow ID, different variantsconst standard = defineFlow({ id: 'onboarding', variantId: 'standard', /* ... */ });const express = defineFlow({ id: 'onboarding', variantId: 'express', /* ... */ });Next steps
Section titled “Next steps”- Steps & Transitions - Learn about step components
- Context - Manage shared state
- Navigation - Control flow movement
- Branching Flows - Deep dive into branching patterns
- Flow Variants - A/B testing and dynamic flows
- Migrations Guide - Handle schema changes