Flow Variants
Flow variants allow you to create multiple versions of the same flow with different structures, steps, or navigation logic. The same step components can be reused across different flow definitions, making it easy to experiment with different user experiences.
What are flow variants?
Section titled “What are flow variants?”Flow variants are different configurations of a flow that share the same id but have different variantId values. This enables:
- A/B testing - Test different onboarding or checkout experiences
- Feature flags - Enable/disable steps based on feature availability
- Role-based flows - Different paths for admins vs users
- Progressive disclosure - Beginner vs expert modes
- User preferences - Express vs detailed flows
- Server-driven flows - Fetch entire flow definitions from your API
Basic example
Section titled “Basic example”Here’s how to define two variants of the same onboarding flow:
import { defineFlow } from "@useflow/react";
type OnboardingContext = { email: string; name: string; preferences?: { notifications: boolean; newsletter: boolean; };};
// Standard flow with all stepsconst standardFlow = defineFlow({ id: "onboarding", variantId: "standard", start: "welcome", steps: { welcome: { next: "account" }, account: { next: "verification" }, verification: { next: "profile" }, profile: { next: "preferences" }, preferences: { next: "complete" }, complete: {}, },});
// Express flow with fewer stepsconst expressFlow = defineFlow({ id: "onboarding", variantId: "express", start: "welcome", steps: { welcome: { next: "account" }, account: { next: "profile" }, profile: { next: "complete" }, complete: {}, },});Using variants
Section titled “Using variants”Runtime selection
Section titled “Runtime selection”Choose which flow to use based on a condition:
import { Flow } from "@useflow/react";
function App() { // Determine which variant to use const useExpress = user.preferences.expressMode; const selectedFlow = useExpress ? expressFlow : standardFlow;
return ( <Flow flow={selectedFlow} initialContext={{ email: "", name: "" }}> {({ renderStep }) => renderStep({ welcome: <WelcomeStep />, account: <AccountStep />, verification: <VerificationStep />, profile: <ProfileStep />, preferences: <PreferencesStep />, complete: <CompleteStep />, }) } </Flow> );}User toggle
Section titled “User toggle”Let users switch between variants:
function App() { const [useExpress, setUseExpress] = useState(false); const selectedFlow = useExpress ? expressFlow : standardFlow;
return ( <div> <label> <input type="checkbox" checked={useExpress} onChange={(e) => setUseExpress(e.target.checked)} /> Use express flow </label>
<Flow flow={selectedFlow} initialContext={{ email: "", name: "" }}> {({ renderStep }) => renderStep({ welcome: <WelcomeStep />, account: <AccountStep />, verification: <VerificationStep />, profile: <ProfileStep />, preferences: <PreferencesStep />, complete: <CompleteStep />, }) } </Flow> </div> );}Providing components for all variants
Section titled “Providing components for all variants”When using flow variants, you must provide components for all steps across all variants. Components use the generic useFlowState() hook to work with any flow:
// ✅ Flow-agnostic component - works with any flowfunction AccountStep() { const { next, setContext, context } = useFlowState<OnboardingContext>();
return ( <div> <input value={context.email} onChange={(e) => setContext({ email: e.target.value })} placeholder="Email" /> <button onClick={() => next()}>Continue</button> </div> );}When you use renderStep(), you provide components for all possible steps across all variants. Only the steps from the active flow will be rendered:
{({ renderStep }) => renderStep({ welcome: <WelcomeStep />, // Used by both variants account: <AccountStep />, // Used by both variants verification: <VerificationStep />, // Only in standard variant profile: <ProfileStep />, // Used by both variants preferences: <PreferencesStep />, // Only in standard variant complete: <CompleteStep />, // Used by both variants })}Common use cases
Section titled “Common use cases”A/B testing
Section titled “A/B testing”Test different flow structures to optimize conversion:
function App() { // Randomly assign users to a variant const variant = Math.random() > 0.5 ? "control" : "treatment"; const selectedFlow = variant === "treatment" ? treatmentFlow : controlFlow;
return ( <Flow flow={selectedFlow} initialContext={{ email: "", name: "" }} onComplete={() => { // Track which variant completed analytics.track("flow_completed", { variant }); }} > {({ renderStep }) => renderStep({ /* ... */ })} </Flow> );}Feature flags
Section titled “Feature flags”Enable/disable steps based on feature availability:
function App() { const features = useFeatureFlags();
// Choose flow based on feature flags const selectedFlow = features.emailVerification ? flowWithVerification : flowWithoutVerification;
return ( <Flow flow={selectedFlow} initialContext={{ email: "", name: "" }}> {({ renderStep }) => renderStep({ /* ... */ })} </Flow> );}Role-based flows
Section titled “Role-based flows”Different paths for different user roles:
function App() { const user = useCurrentUser();
// Admin gets full flow, regular users get simplified flow const selectedFlow = user.role === "admin" ? adminFlow : userFlow;
return ( <Flow flow={selectedFlow} initialContext={{}}> {({ renderStep }) => renderStep({ /* ... */ })} </Flow> );}User preferences
Section titled “User preferences”Let users choose their experience level:
const beginnerFlow = defineFlow({ id: "tutorial", variantId: "beginner", start: "intro", steps: { intro: { next: "basics" }, basics: { next: "practice" }, practice: { next: "tips" }, tips: { next: "complete" }, complete: {}, },});
const expertFlow = defineFlow({ id: "tutorial", variantId: "expert", start: "overview", steps: { overview: { next: "complete" }, complete: {}, },});Remote flows
Section titled “Remote flows”Flow definitions are JSON-serializable, so you can fetch them from your API:
import { defineFlow } from "@useflow/react";
function App() { const [flow, setFlow] = useState(null);
useEffect(() => { // Fetch flow definition from your backend fetch("/api/flows/onboarding") .then((r) => r.json()) .then((flowConfig) => { const remoteFlow = defineFlow(flowConfig); setFlow(remoteFlow); }); }, []);
if (!flow) return <div>Loading...</div>;
return ( <Flow flow={flow} initialContext={{}}> {({ renderStep }) => renderStep({ /* ... */ })} </Flow> );}Your API can return different flow definitions based on:
- User ID (personalized flows)
- Tenant ID (multi-tenant apps)
- A/B test assignment
- Feature flags
- User preferences
Persistence with variants
Section titled “Persistence with variants”Each variant maintains separate saved state. When a user switches variants, their progress is preserved for each:
import { createPersister, createLocalStorageStore } from "@useflow/react";
const persister = createPersister({ store: createLocalStorageStore()});
function App() { const [useExpress, setUseExpress] = useState(false); const selectedFlow = useExpress ? expressFlow : standardFlow;
return ( <Flow flow={selectedFlow} persister={persister} initialContext={{}} > {({ renderStep }) => renderStep({ /* ... */ })} </Flow> );}Storage keys include the variant ID:
onboarding:standard:state- State for standard variantonboarding:express:state- State for express variant
Users can switch between variants and continue where they left off in each.
Learn more about persistence →
Type safety
Section titled “Type safety”TypeScript automatically infers the union of all step names when you use multiple flows and enforces that you provide components for every step:
const standardFlow = defineFlow({ id: "onboarding", variantId: "standard", start: "welcome", steps: { welcome: { next: "account" }, account: { next: "verification" }, verification: { next: "complete" }, complete: {}, },});
const expressFlow = defineFlow({ id: "onboarding", variantId: "express", start: "welcome", steps: { welcome: { next: "account" }, account: { next: "complete" }, complete: {}, },});
// TypeScript knows about all steps from both flowsconst selectedFlow = useExpress ? expressFlow : standardFlow;
<Flow flow={selectedFlow} initialContext={{}}> {({ renderStep }) => renderStep({ welcome: <WelcomeStep />, account: <AccountStep />, verification: <VerificationStep />, // ✅ Required even though only in standard complete: <CompleteStep />, // Missing any step would be ❌ TypeScript Error }) }</Flow>This prevents runtime errors when users switch between variants - if a component is missing, TypeScript catches it at compile time.
Best practices
Section titled “Best practices”1. Share the same flow ID
Section titled “1. Share the same flow ID”Variants of the same flow should use the same id but different variantId:
// ✅ Good - Same flow, different variantsconst standard = defineFlow({ id: "onboarding", variantId: "standard", /* ... */ });const express = defineFlow({ id: "onboarding", variantId: "express", /* ... */ });
// ❌ Avoid - Different flow IDs break persistence switchingconst standard = defineFlow({ id: "onboarding-standard", /* ... */ });const express = defineFlow({ id: "onboarding-express", /* ... */ });2. Use semantic variant IDs
Section titled “2. Use semantic variant IDs”Choose descriptive variant names:
// ✅ Good - Clear meaningvariantId: "express"variantId: "detailed"variantId: "admin"variantId: "mobile"
// ❌ Avoid - Generic namesvariantId: "variant1"variantId: "v2"variantId: "test"3. Provide components for all steps
Section titled “3. Provide components for all steps”When using multiple variants, provide components for all possible steps:
// If flow A has steps [a, b, c] and flow B has steps [a, b, d]// You must provide components for [a, b, c, d]{({ renderStep }) => renderStep({ a: <StepA />, b: <StepB />, c: <StepC />, // Only used by flow A d: <StepD />, // Only used by flow B })}4. Keep context types compatible
Section titled “4. Keep context types compatible”Ensure all variants use compatible context types:
// ✅ Good - Compatible context typestype OnboardingContext = { email: string; name: string; // Optional fields work across variants verificationCode?: string; preferences?: PreferencesData;};
const standardFlow = defineFlow({ /* ... */ });const expressFlow = defineFlow({ /* ... */ });5. Track variant usage
Section titled “5. Track variant usage”Use analytics to understand which variants perform best:
<Flow flow={selectedFlow} initialContext={{}} onComplete={() => { analytics.track("flow_completed", { flowId: selectedFlow.id, variantId: selectedFlow.variantId, }); }} onNext={({ from, to }) => { analytics.track("step_transition", { flowId: selectedFlow.id, variantId: selectedFlow.variantId, from, to, }); }}> {({ renderStep }) => renderStep({ /* ... */ })}</Flow>Examples
Section titled “Examples”Check out the Flow Variants demo in our examples repository to see flow variants in action.
Next steps
Section titled “Next steps”- Branching - Conditional navigation within flows
- Persistence - Save and restore flow progress
- Callbacks - React to flow events
- Migrations - Handle flow structure changes