Steps & Transitions
Steps are the individual stages in your flow. Each step is a React component that renders UI and allows users to interact with your flow.
Step components
Section titled “Step components”A step component is just a regular React component that uses the useFlowState() hook to access flow state and navigation:
import { useFlowState } from '@useflow/react';import { Button } from './ui/button';
function WelcomeStep() { const { next, context } = useFlowState();
return ( <div> <h1>Welcome!</h1> <p>Let's get started with your onboarding.</p> <Button onClick={() => next()}> Continue </Button> </div> );}Accessing flow state
Section titled “Accessing flow state”The useFlowState() hook gives you access to:
function MyStep() { const { // Navigation next, // Move forward skip, // Skip this step back, // Go back reset, // Start over canGoBack, // Whether back is available canGoNext, // Whether next is available
// State context, // Current context data stepId, // Current step ID status, // Flow status: 'active' | 'complete'
// Metadata history, // Navigation history path, // Path taken through flow steps, // All step definitions nextSteps, // Possible next steps from here
// Persistence isRestoring, // Whether restoring saved state save, // Manually save (when saveMode="manual") } = useFlowState();
return <div>...</div>;}Step patterns
Section titled “Step patterns”Linear navigation
Section titled “Linear navigation”Simple forward progression - just call next():
function ProfileStep() { const { next } = useFlowState(); const [name, setName] = useState('');
return ( <form onSubmit={(e) => { e.preventDefault(); next({ name }); // Save name and move forward }}> <input value={name} onChange={(e) => setName(e.target.value)} /> <button type="submit">Continue</button> </form> );}Form validation
Section titled “Form validation”Validate before allowing navigation:
function ProfileStep() { const { next, context } = useFlowState(); const [name, setName] = useState(context.name || ''); const [email, setEmail] = useState(context.email || '');
const isValid = name.length > 0 && email.includes('@');
return ( <div> <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" /> <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" /> <button onClick={() => next({ name, email })} disabled={!isValid} > Continue </button> </div> );}Backward navigation
Section titled “Backward navigation”Allow users to go back:
function PreferencesStep() { const { next, back, canGoBack } = useFlowState(); const [theme, setTheme] = useState('light');
return ( <div> <h2>Choose your preferences</h2>
<select value={theme} onChange={(e) => setTheme(e.target.value)}> <option value="light">Light</option> <option value="dark">Dark</option> </select>
<div> {canGoBack && ( <button onClick={() => back()}>Back</button> )} <button onClick={() => next({ theme })}>Continue</button> </div> </div> );}Skipping steps
Section titled “Skipping steps”Allow users to skip optional steps:
function PreferencesStep() { const { next, skip } = useFlowState(); const [preferences, setPreferences] = useState({});
return ( <div> <h2>Optional: Set your preferences</h2>
{/* Form fields */}
<div> <button onClick={() => skip({ skippedPreferences: true })}> Skip for now </button> <button onClick={() => next({ preferences })}> Save preferences </button> </div> </div> );}Branching - component-driven
Section titled “Branching - component-driven”Explicitly choose which path to take:
function SetupPreferenceStep() { const { next } = useFlowState();
return ( <div> <h2>How would you like to set up?</h2>
<button onClick={() => next('quick')}> Quick Setup (Skip preferences) </button>
<button onClick={() => next('advanced')}> Advanced Setup (Configure everything) </button> </div> );}Loading states
Section titled “Loading states”Show loading UI while restoring:
function MyStep() { const { isRestoring, next } = useFlowState();
if (isRestoring) { return <Spinner />; }
return ( <div> <h1>Content</h1> <button onClick={() => next()}>Continue</button> </div> );}Context updates
Section titled “Context updates”Update context in multiple ways:
function MyStep() { const { next, context } = useFlowState();
// 1. Merge object (shallow merge) next({ name: 'Alice' });
// 2. Updater function (full control) next((ctx) => ({ ...ctx, count: ctx.count + 1, timestamp: Date.now() }));
// 3. Two-argument form (target + update) next('targetStep', { name: 'Alice' });
return <div>...</div>;}Rendering steps
Section titled “Rendering steps”There are two ways to render steps in your flow:
1. Inline with renderStep()
Section titled “1. Inline with renderStep()”Map step IDs to components:
<Flow flow={myFlow} initialContext={{}}> {({ renderStep }) => renderStep({ welcome: <WelcomeStep />, profile: <ProfileStep />, preferences: <PreferencesStep />, complete: <CompleteStep /> })}</Flow>Pros:
- Simple and declarative
- Easy to pass props to steps
- Good for small flows
When to use:
- Small flows (< 10 steps)
- When steps need different props
- When you want explicit control
2. Component mapping
Section titled “2. Component mapping”Create a reusable mapping:
const stepComponents = { welcome: WelcomeStep, profile: ProfileStep, preferences: PreferencesStep, complete: CompleteStep};
<Flow flow={myFlow} initialContext={{}}> {({ stepId }) => { const StepComponent = stepComponents[stepId]; return <StepComponent />; }}</Flow>Pros:
- More flexible
- Easier to test individual steps
- Better for large flows
When to use:
- Large flows (10+ steps)
- When steps don’t need props
- When you want maximum reusability
Step organization
Section titled “Step organization”Single file per step
Section titled “Single file per step”Organize steps in their own files:
src/flows/onboarding/├── flow.ts├── components/│ ├── WelcomeStep.tsx│ ├── ProfileStep.tsx│ ├── PreferencesStep.tsx│ └── CompleteStep.tsx└── FlowDemo.tsxShared steps
Section titled “Shared steps”Reuse steps across flows:
src/shared-steps/├── WelcomeStep.tsx # Generic welcome├── ProfileStep.tsx # Collect name/email├── PreferencesStep.tsx # Theme/notifications└── CompleteStep.tsx # Generic completionThen import in your flows:
import { WelcomeStep } from '@/shared-steps/WelcomeStep';import { ProfileStep } from '@/shared-steps/ProfileStep';
<Flow flow={myFlow} initialContext={{}}> {({ renderStep }) => renderStep({ welcome: <WelcomeStep />, profile: <ProfileStep />, complete: <CompleteStep /> })}</Flow>Step variants
Section titled “Step variants”Create variants of the same step:
function ProfileStep({ includeAvatar = false }) { const { next } = useFlowState(); const [name, setName] = useState('');
return ( <div> <input value={name} onChange={(e) => setName(e.target.value)} /> {includeAvatar && <AvatarUpload />} <button onClick={() => next({ name })}>Continue</button> </div> );}
// UsagerenderStep({ profile: <ProfileStep includeAvatar={true} />,})Type safety
Section titled “Type safety”Type-safe hook (recommended)
Section titled “Type-safe hook (recommended)”Use the custom hook from defineFlow for type-safe navigation:
const myFlow = defineFlow({ id: 'onboarding', start: 'welcome', steps: { welcome: { next: 'profile' }, profile: { next: ['business', 'personal'] }, business: { next: 'complete' }, personal: { next: 'complete' }, complete: {} }});
function ProfileStep() { // Type-safe: knows valid next steps are 'business' | 'personal' const { next } = myFlow.useFlowState({ step: 'profile' });
next('business'); // ✅ Valid next('personal'); // ✅ Valid next('welcome'); // ❌ TypeScript error}Generic hook
Section titled “Generic hook”Use the generic useFlowState() for more flexibility:
function ProfileStep() { // Less type safety, but more flexible const { next, context } = useFlowState<{ name: string }>();
// context is typed as { name: string } console.log(context.name);
next({ name: 'Alice' });}Step lifecycle
Section titled “Step lifecycle”Understanding the step lifecycle helps debug and optimize:
1. Step mount
Section titled “1. Step mount”When a step is rendered for the first time:
function MyStep() { const { stepId } = useFlowState();
useEffect(() => { console.log(`Step ${stepId} mounted`);
// Track analytics trackPageView(stepId);
return () => { console.log(`Step ${stepId} unmounted`); }; }, [stepId]);
return <div>...</div>;}2. Context updates
Section titled “2. Context updates”When context changes (from another step or within this step):
function MyStep() { const { context } = useFlowState();
useEffect(() => { console.log('Context updated:', context); }, [context]);
return <div>...</div>;}3. Step transition
Section titled “3. Step transition”When navigating away from the step:
function MyStep() { const { next } = useFlowState();
const handleNext = () => { // Do something before leaving console.log('Leaving step');
next({ someData: 'value' }); };
return <button onClick={handleNext}>Continue</button>;}History & path
Section titled “History & path”Track navigation through your flow:
History
Section titled “History”Complete record of all step visits:
function MyStep() { const { history } = useFlowState();
// history is an array of: // { // stepId: string, // startedAt: number, // completedAt?: number, // action?: 'next' | 'skip' | 'back' // }
const visitedSteps = history.map(h => h.stepId); const timeOnEachStep = history.map(h => ({ step: h.stepId, duration: h.completedAt ? h.completedAt - h.startedAt : null }));
return <div>You've visited: {visitedSteps.join(' → ')}</div>;}The route taken to reach the current step (used for back navigation):
function MyStep() { const { path, back, canGoBack } = useFlowState();
// path is the linear path to get here // (may be shorter than history if user went back)
const pathSteps = path.map(p => p.stepId);
return ( <div> <p>Path: {pathSteps.join(' → ')}</p> {canGoBack && ( <button onClick={() => back()}> Back to {path[path.length - 2].stepId} </button> )} </div> );}Complete example
Section titled “Complete example”Here’s a complete step with all features:
import { useFlowState } from '@useflow/react';import { useEffect, useState } from 'react';import { Button } from './ui/button';import { Input } from './ui/input';
type ProfileContext = { name: string; email: string;};
function ProfileStep() { const { next, back, skip, context, stepId, canGoBack, isRestoring, history } = useFlowState<ProfileContext>();
const [name, setName] = useState(context.name || ''); const [email, setEmail] = useState(context.email || '');
// Track page view on mount useEffect(() => { console.log(`Entered step: ${stepId}`); // trackPageView(stepId); }, [stepId]);
// Show loading while restoring if (isRestoring) { return <div>Loading...</div>; }
// Validation const isValid = name.length > 0 && email.includes('@');
// Show how long user spent on previous steps const previousDuration = history .slice(0, -1) // Exclude current step .reduce((total, h) => { const duration = h.completedAt ? h.completedAt - h.startedAt : 0; return total + duration; }, 0);
return ( <div className="space-y-4"> <div> <h2>Profile Information</h2> <p className="text-sm text-gray-500"> Time spent so far: {Math.round(previousDuration / 1000)}s </p> </div>
<div> <label>Name</label> <Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Enter your name" /> </div>
<div> <label>Email</label> <Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter your email" /> </div>
<div className="flex gap-2"> {canGoBack && ( <Button variant="outline" onClick={() => back()}> Back </Button> )}
<Button variant="ghost" onClick={() => skip({ skippedProfile: true })} > Skip </Button>
<Button onClick={() => next({ name, email })} disabled={!isValid} > Continue </Button> </div> </div> );}Best practices
Section titled “Best practices”1. Keep steps focused
Section titled “1. Keep steps focused”Each step should have a single purpose:
// ❌ Avoid: One step doing too muchfunction MegaStep() { return ( <> <ProfileForm /> <PreferencesForm /> <PaymentForm /> </> );}
// ✅ Better: Separate stepsfunction ProfileStep() { /* ... */ }function PreferencesStep() { /* ... */ }function PaymentStep() { /* ... */ }2. Initialize from context
Section titled “2. Initialize from context”Always initialize local state from context:
// ✅ Good: Initialize from contextfunction MyStep() { const { context } = useFlowState(); const [name, setName] = useState(context.name || ''); // ...}
// ❌ Bad: Ignoring contextfunction MyStep() { const [name, setName] = useState(''); // Loses data on revisit // ...}3. Validate before navigation
Section titled “3. Validate before navigation”Don’t let users proceed with invalid data:
function MyStep() { const [email, setEmail] = useState(''); const isValid = email.includes('@');
return ( <button onClick={() => next({ email })} disabled={!isValid} > Continue </button> );}4. Use semantic actions
Section titled “4. Use semantic actions”Choose the right navigation method:
// User completed the stepnext({ data: value });
// User chose to skipskip({ skippedStep: true });
// User went backback();Next steps
Section titled “Next steps”- Context - Manage shared state across steps
- Navigation - Advanced navigation patterns
- Linear Flows Guide - Build simple linear flows
- Branching Flows - Implement conditional logic