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

Navigation

Navigation is how users move through your flow. useFlowState provides several navigation methods that are type-safe, flexible, and work seamlessly with your flow definition.

The primary way to progress through a flow:

function MyStep() {
const { next } = useFlowState();
return (
<button onClick={() => next()}>
Continue
</button>
);
}

Update context while navigating:

function ProfileStep() {
const { next, context } = useFlowState();
const [name, setName] = useState('');
return (
<>
{/* Object update - merges with existing context (partial update) */}
<button onClick={() => next({ name })}>
Continue (keeps other fields)
</button>
{/* Function update - full control over context */}
<button onClick={() => next(() => ({
name // Replaces entire context with just name!
}))}>
Continue (clears other fields)
</button>
{/* Function update - preserve existing context */}
<button onClick={() => next((ctx) => ({
...ctx, // Spread to keep existing fields
name,
updatedAt: Date.now()
}))}>
Continue (keeps other fields + timestamp)
</button>
</>
);
}

For branching flows, specify the destination:

function ChoiceStep() {
const { next } = useFlowState();
return (
<div>
<button onClick={() => next('option1')}>
Choose Option 1
</button>
<button onClick={() => next('option2')}>
Choose Option 2
</button>
</div>
);
}

Combine both:

function ChoiceStep() {
const { next, context } = useFlowState();
return (
<>
{/* Object update with target - merges with existing */}
<button onClick={() => next('business', { userType: 'business' })}>
Business Account (keeps other data)
</button>
{/* Function update with target - full control */}
<button onClick={() => next('personal', () => ({
userType: 'personal' // Warning: replaces entire context!
}))}>
Personal Account (clears other data)
</button>
{/* Function with spread to preserve context */}
<button onClick={() => next('personal', (ctx) => ({
...ctx,
userType: 'personal',
previousType: ctx.userType
}))}>
Personal Account (keeps other data)
</button>
</>
);
}

Semantically different from next() - indicates the user chose to skip:

function OptionalStep() {
const { next, skip } = useFlowState();
return (
<div>
<button onClick={() => next({ preferences: data })}>
Save Preferences
</button>
<button onClick={() => skip({ skippedPreferences: true })}>
Skip for now
</button>
</div>
);
}

Go back to the previous step:

function MyStep() {
const { next, back, canGoBack } = useFlowState();
return (
<div>
{canGoBack && (
<button onClick={() => back()}>
Back
</button>
)}
<button onClick={() => next()}>
Continue
</button>
</div>
);
}

back() uses the path (not history) to determine the previous step:

// Flow: welcome → profile → preferences → complete
// User navigates: welcome → profile → preferences
// Path: [welcome, profile, preferences]
// back() goes to: profile
// If user then goes back again:
// Path: [welcome, profile]
// back() goes to: welcome

back() only works within the current flow session:

  • ❌ Cannot go back from the first step
  • ❌ Cannot go back after page reload (unless you track path in persistence)
  • ✅ Works during active session

Reset the flow to the beginning:

function CompleteStep() {
const { reset, context } = useFlowState();
return (
<div>
<h1>Completed!</h1>
<p>Welcome, {context.name}</p>
<button onClick={() => reset()}>
Start Over
</button>
</div>
);
}

reset() returns to the start step with fresh initial context.

Simple forward progression:

const flow = defineFlow({
id: 'linear',
start: 'step1',
steps: {
step1: { next: 'step2' },
step2: { next: 'step3' },
step3: { next: 'complete' },
complete: {}
}
});
function Step1() {
const { next } = flow.useFlowState({ step: 'step1' });
return <button onClick={() => next()}>Next</button>;
}

Component explicitly chooses the path:

const flow = defineFlow({
id: 'branching',
start: 'choice',
steps: {
choice: { next: ['option1', 'option2'] },
option1: { next: 'complete' },
option2: { next: 'complete' },
complete: {}
}
});
function ChoiceStep() {
const { next } = flow.useFlowState({ step: 'choice' });
// Component decides which path
return (
<div>
<button onClick={() => next('option1')}>Option 1</button>
<button onClick={() => next('option2')}>Option 2</button>
</div>
);
}

Flow automatically decides based on context:

const flow = defineFlow({
id: 'auto-branch',
start: 'userType',
steps: {
userType: { next: ['business', 'personal'] },
business: { next: 'complete' },
personal: { next: 'complete' },
complete: {}
}
}).with((steps) => ({
resolvers: {
userType: (ctx) =>
ctx.userType === 'business' ? steps.business : steps.personal
}
}));
function UserTypeStep() {
const { next } = flow.useFlowState({ step: 'userType' });
// Just update context, resolver handles branching
return (
<div>
<button onClick={() => next({ userType: 'business' })}>
Business
</button>
<button onClick={() => next({ userType: 'personal' })}>
Personal
</button>
</div>
);
}

Skip steps based on conditions:

function PreferencesStep() {
const { next, skip, context } = useFlowState();
// Skip if user is in express mode
if (context.mode === 'express') {
useEffect(() => {
skip({ skippedPreferences: true });
}, []);
return <div>Skipping preferences...</div>;
}
return <div>Configure preferences...</div>;
}

You can check if back navigation is available using canGoBack:

function MyStep() {
const { back, canGoBack } = useFlowState();
return (
<div>
<button onClick={() => back()} disabled={!canGoBack}>
Back
</button>
</div>
);
}

Back navigation is available when:

  • User is not on the first step
  • Flow status is “active”

See possible next destinations:

function MyStep() {
const { nextSteps } = useFlowState();
if (!nextSteps) {
return <div>Final step!</div>;
}
if (nextSteps.length === 1) {
return <div>Next: {nextSteps[0]}</div>;
}
return (
<div>
Choose next step:
{nextSteps.map(step => (
<button key={step} onClick={() => next(step)}>
Go to {step}
</button>
))}
</div>
);
}

Flow completion status:

function MyStep() {
const { status, context } = useFlowState();
if (status === 'complete') {
return <div>Flow completed with: {JSON.stringify(context)}</div>;
}
return <div>Flow in progress...</div>;
}

Status values:

  • "active" - Flow in progress
  • "complete" - Flow finished (reached terminal step)

Using the flow’s custom hook provides type-safe navigation:

const flow = defineFlow({
id: 'myFlow',
start: 'step1',
steps: {
step1: { next: 'step2' },
step2: { next: ['option1', 'option2'] },
option1: { next: 'complete' },
option2: { next: 'complete' },
complete: {}
}
});
function Step1() {
const { next } = flow.useFlowState({ step: 'step1' });
next(); // ✅ Valid - goes to 'step2'
next('step2'); // ✅ Valid - explicit target
next('option1'); // ❌ TypeScript error - not a valid next step
}
function Step2() {
const { next } = flow.useFlowState({ step: 'step2' });
next('option1'); // ✅ Valid
next('option2'); // ✅ Valid
next('complete'); // ❌ TypeScript error
next(); // ❌ TypeScript error - must specify target
}

Context updates are type-checked:

type MyContext = {
name: string;
age: number;
};
function MyStep() {
const { next } = useFlowState<MyContext>();
next({ name: 'Alice', age: 30 }); // ✅ Valid
next({ name: 'Bob' }); // ✅ Valid (partial update)
next({ invalid: true }); // ❌ TypeScript error
}

React to navigation events:

Triggered on any navigation (next, skip, or back):

<Flow
flow={myFlow}
initialContext={{}}
onTransition={({ from, to, direction, oldContext, newContext }) => {
console.log(`Navigated from ${from} to ${to} (${direction})`);
// Track all navigation
trackEvent('navigation', {
from,
to,
direction, // "forward" | "backward"
context: newContext
});
}}
>
{({ renderStep }) => renderStep({ /* ... */ })}
</Flow>

Triggered specifically when next() is called:

<Flow
flow={myFlow}
initialContext={{}}
onNext={({ from, to, oldContext, newContext }) => {
console.log(`Navigated from ${from} to ${to}`);
console.log('Context changed:', oldContext, '', newContext);
// Track step completion
trackEvent('step_completed', { step: from, nextStep: to });
}}
>
{({ renderStep }) => renderStep({ /* ... */ })}
</Flow>

Triggered when skip() is called:

<Flow
flow={myFlow}
initialContext={{}}
onSkip={({ from, to, newContext }) => {
console.log(`Skipped ${from}, went to ${to}`);
// Track which steps users skip
trackEvent('step_skipped', { step: from });
}}
>
{({ renderStep }) => renderStep({ /* ... */ })}
</Flow>

Triggered when back() is called:

<Flow
flow={myFlow}
initialContext={{}}
onBack={({ from, to }) => {
console.log(`Went back from ${from} to ${to}`);
trackEvent('step_back', { from, to });
}}
>
{({ renderStep }) => renderStep({ /* ... */ })}
</Flow>

Triggered when flow completes:

<Flow
flow={myFlow}
initialContext={{}}
onComplete={async ({ context }) => {
console.log('Flow completed!', context);
// Submit to API
await submitOnboarding(context);
// Redirect
router.push('/dashboard');
}}
>
{({ renderStep }) => renderStep({ /* ... */ })}
</Flow>

Navigate outside of step components:

function App() {
return (
<Flow flow={myFlow} initialContext={{}}>
{({ renderStep, next, stepId }) => (
<div>
{/* External navigation controls */}
<button onClick={() => next()}>Skip to next</button>
{/* Current step */}
<div>{renderStep({ /* ... */ })}</div>
{/* Step indicator */}
<div>Current: {stepId}</div>
</div>
)}
</Flow>
);
}

Add keyboard shortcuts:

function MyStep() {
const { next, back, canGoBack } = useFlowState();
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
next();
} else if (e.key === 'Escape' && canGoBack) {
back();
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [next, back, canGoBack]);
return <div>Press Enter to continue, Esc to go back</div>;
}

Automatically navigate after a condition:

function ProcessingStep() {
const { next } = useFlowState();
const [isProcessing, setIsProcessing] = useState(true);
useEffect(() => {
async function process() {
await someAsyncOperation();
setIsProcessing(false);
next({ processed: true });
}
process();
}, [next]);
if (isProcessing) {
return <Spinner />;
}
return <div>Processing complete!</div>;
}

Navigate based on validation:

function FormStep() {
const { next } = useFlowState();
const [data, setData] = useState({});
const [errors, setErrors] = useState({});
const handleSubmit = async () => {
const validationErrors = validate(data);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
try {
await saveData(data);
next(data);
} catch (error) {
setErrors({ submit: error.message });
}
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
{/* Form fields */}
<button type="submit">Continue</button>
</form>
);
}

Track user journey through the flow:

History - Complete record of all step visits:

// User path: A → B → C → B (back) → C
// History: [A, B, C, B, C]

Path - Linear route to current step:

// User path: A → B → C → B (back) → C
// Path: [A, B, C]
function MyStep() {
const { history } = useFlowState();
// Get all visited steps
const visitedSteps = history.map(h => h.stepId);
// Calculate time spent on each step
const durations = history
.filter(h => h.completedAt)
.map(h => ({
step: h.stepId,
duration: h.completedAt! - h.startedAt,
action: h.action // 'next' | 'skip' | 'back'
}));
// Count steps where user went back
const backCount = history.filter(h => h.action === 'back').length;
return (
<div>
<p>Total steps visited: {history.length}</p>
<p>Went back {backCount} times</p>
</div>
);
}
function Breadcrumbs() {
const { path, stepId } = useFlowState();
return (
<div className="breadcrumbs">
{path.map((entry, index) => (
<span key={entry.stepId}>
{entry.stepId}
{index < path.length - 1 && ''}
</span>
))}
</div>
);
}

Always validate data before moving forward:

function MyStep() {
const { next } = useFlowState();
const [data, setData] = useState({});
const isValid = () => {
return data.name && data.email?.includes('@');
};
return (
<button
onClick={() => next(data)}
disabled={!isValid()}
>
Continue
</button>
);
}

When possible, allow users to go back:

function MyStep() {
const { next, back, canGoBack } = useFlowState();
return (
<div className="navigation">
{canGoBack && <button onClick={back}>Back</button>}
<button onClick={next}>Continue</button>
</div>
);
}

Choose the right navigation method:

// ✅ Good: Semantic actions
next({ data }); // User completed step
skip({ skipped: true }); // User chose to skip
back(); // User went back
// ❌ Bad: Using next() for everything
next({ skipped: true }); // Unclear if completed or skipped

Check for terminal steps:

function MyStep() {
const { next, nextSteps } = useFlowState();
if (!nextSteps) {
// Terminal step - no next
return <div>This is the end!</div>;
}
return <button onClick={next}>Continue</button>;
}

Use callbacks for analytics:

<Flow
flow={myFlow}
initialContext={{}}
onNext={({ from, to }) => {
trackEvent('flow_step_completed', { from, to });
}}
onSkip={({ from }) => {
trackEvent('flow_step_skipped', { step: from });
}}
onComplete={({ context }) => {
trackEvent('flow_completed', {
duration: Date.now() - context.startedAt
});
}}
>
{({ renderStep }) => renderStep({ /* ... */ })}
</Flow>