Context
Context is the shared state that flows through all steps in your multi-step process. It stores data collected from users, intermediate calculations, and any other information needed across steps.
What is context?
Section titled “What is context?”Context is a plain JavaScript object that:
- Persists across step transitions
- Accumulates data as users progress
- Is type-safe when using TypeScript
- Can be saved and restored automatically
type OnboardingContext = { name: string; email: string; preferences?: { theme: "light" | "dark"; notifications: boolean; }; completedAt?: number;};Defining context types
Section titled “Defining context types”Basic context
Section titled “Basic context”Define your context type and pass it to defineFlow:
import { defineFlow } from "@useflow/react";
type MyContext = { name: string; age: number;};
const myFlow = defineFlow({ id: "myFlow", start: "welcome", steps: { welcome: { next: "complete" }, complete: {}, },});Complex context
Section titled “Complex context”Use nested objects and optional fields:
type OnboardingContext = { // Required fields userId: string; startedAt: number;
// User info (collected in profile step) profile?: { name: string; email: string; avatar?: string; };
// Business-specific (only for business users) business?: { companyName: string; industry: string; size: "small" | "medium" | "large"; };
// Preferences (optional step) preferences?: { theme: "light" | "dark"; notifications: boolean; emailFrequency: "daily" | "weekly" | "never"; };
// Tracking skippedSteps?: string[]; completedAt?: number;};Shared base types
Section titled “Shared base types”Reuse common fields across flows:
// Base context shared across all flowstype BaseContext = { userId: string; startedAt: number; completedAt?: number;};
// Specific flow contexts extend the basetype OnboardingContext = BaseContext & { name: string; email: string;};
type CheckoutContext = BaseContext & { cartItems: CartItem[]; shippingAddress: Address; paymentMethod: PaymentMethod;};Accessing context
Section titled “Accessing context”In step components
Section titled “In step components”Use the useFlowState() hook to access context:
function ProfileStep() { const { context } = useFlowState<OnboardingContext>();
// Access context data console.log(context.name); console.log(context.email);
return <div>Welcome, {context.name}!</div>;}Type-safe access
Section titled “Type-safe access”TypeScript infers the context type from your flow definition:
const myFlow = defineFlow({ id: "onboarding", start: "welcome", steps: { /* ... */ },});
function MyStep() { const { context } = myFlow.useFlowState({ step: "profile" });
// ✅ TypeScript knows context is OnboardingContext console.log(context.name); // ✅ Valid console.log(context.invalid); // ❌ TypeScript error}Updating context
Section titled “Updating context”There are three ways to update context when navigating:
1. Object merge (shallow)
Section titled “1. Object merge (shallow)”Pass an object to merge with current context:
function ProfileStep() { const { next, context } = useFlowState(); const [name, setName] = useState("");
const handleNext = () => { // Shallow merge with current context next({ name });
// Before: { email: 'user@example.com' } // After: { email: 'user@example.com', name: 'Alice' } };
return <button onClick={handleNext}>Continue</button>;}2. Updater function (full control)
Section titled “2. Updater function (full control)”Use a function for complex updates:
function PreferencesStep() { const { next } = useFlowState();
const handleNext = () => { // Function receives current context, returns new context next((currentContext) => ({ ...currentContext, preferences: { theme: "dark", notifications: true, }, updatedAt: Date.now(), })); };
return <button onClick={handleNext}>Continue</button>;}3. Conditional updates
Section titled “3. Conditional updates”Update based on current context:
function MyStep() { const { next, context } = useFlowState();
const handleNext = () => { next((ctx) => ({ ...ctx, // Increment counter visitCount: (ctx.visitCount || 0) + 1,
// Add to array visitedSteps: [...(ctx.visitedSteps || []), "myStep"],
// Conditional update isFirstTime: ctx.visitCount === undefined, })); };
return <button onClick={handleNext}>Continue</button>;}Context patterns
Section titled “Context patterns”Collecting data progressively
Section titled “Collecting data progressively”Build up context across multiple steps:
// Step 1: Basic infofunction Step1() { const { next } = useFlowState(); return ( <button onClick={() => next({ name: "Alice", email: "alice@example.com" })}> Continue </button> );}
// Step 2: Add preferencesfunction Step2() { const { next, context } = useFlowState(); // context = { name: 'Alice', email: 'alice@example.com' }
return ( <button onClick={() => next({ theme: "dark", notifications: true })}> Continue </button> );}
// Step 3: Final contextfunction Step3() { const { context } = useFlowState(); // context = { // name: 'Alice', // email: 'alice@example.com', // theme: 'dark', // notifications: true // }}Initializing context
Section titled “Initializing context”Set initial values when mounting the flow:
<Flow flow={myFlow} initialContext={{ userId: user.id, startedAt: Date.now(), theme: "light", notifications: false, }}> {({ renderStep }) => renderStep({ /* ... */ }) }</Flow>Conditional fields
Section titled “Conditional fields”Add fields only when needed:
function UserTypeStep() { const { next } = useFlowState();
const handleBusinessUser = () => { next({ userType: "business", // Add business-specific fields business: { companyName: "", industry: "", }, }); };
const handlePersonalUser = () => { next({ userType: "personal", // No business fields for personal users }); };
return ( <div> <button onClick={handleBusinessUser}>Business</button> <button onClick={handlePersonalUser}>Personal</button> </div> );}Tracking metadata
Section titled “Tracking metadata”Store navigation metadata in context:
function MyStep() { const { next, stepId } = useFlowState();
const handleNext = () => { next((ctx) => ({ ...ctx, // Track visited steps visitedSteps: [...(ctx.visitedSteps || []), stepId],
// Track last action time lastActionAt: Date.now(),
// Track step durations stepDurations: { ...ctx.stepDurations, [stepId]: Date.now() - ctx.stepStartedAt, }, })); };
return <button onClick={handleNext}>Continue</button>;}Resetting fields
Section titled “Resetting fields”Clear fields when navigating back:
function BusinessDetailsStep() { const { next } = useFlowState();
const handleNext = () => { next((ctx) => { // Remove business fields if user changes mind const { business, ...rest } = ctx; return rest; }); };
return <button onClick={handleNext}>Not a business</button>;}Context in callbacks
Section titled “Context in callbacks”Access context in flow callbacks:
<Flow flow={myFlow} initialContext={{}} onNext={({ from, to, newContext }) => { console.log(`Moved from ${from} to ${to}`); console.log("New context:", newContext);
// Track in analytics trackEvent("step_completed", { step: from, context: newContext, }); }} onComplete={({ context }) => { console.log("Flow completed with context:", context);
// Submit to API await submitOnboarding(context); }}> {({ renderStep }) => renderStep({ /* ... */ }) }</Flow>Context best practices
Section titled “Context best practices”1. Initialize required fields
Section titled “1. Initialize required fields”Always provide required fields in initialContext:
// ❌ Bad: Missing required fields<Flow flow={myFlow} initialContext={{}}>
// ✅ Good: All required fields provided<Flow flow={myFlow} initialContext={{ userId: user.id, startedAt: Date.now() }}>2. Use optional fields for progressive data
Section titled “2. Use optional fields for progressive data”Mark fields optional if collected in later steps:
type Context = { userId: string; // Required from start startedAt: number; // Required from start name?: string; // Collected in step 1 email?: string; // Collected in step 1 preferences?: { // Collected in step 2 theme: "light" | "dark"; notifications: boolean; };};3. Avoid deep mutations
Section titled “3. Avoid deep mutations”Use spread operators to avoid mutating nested objects:
// ❌ Bad: Mutating nested objectnext((ctx) => { ctx.preferences.theme = "dark"; // Mutation! return ctx;});
// ✅ Good: Create new nested objectnext((ctx) => ({ ...ctx, preferences: { ...ctx.preferences, theme: "dark", },}));4. Keep context serializable
Section titled “4. Keep context serializable”Avoid storing non-serializable values (functions, class instances, etc.):
// ❌ Bad: Non-serializabletype Context = { name: string; validate: () => boolean; // Function - can't be saved createdAt: Date; // Date object - loses type when saved};
// ✅ Good: Serializabletype Context = { name: string; createdAt: number; // Timestamp instead of Date};5. Use semantic field names
Section titled “5. Use semantic field names”Choose clear, descriptive names:
// ❌ Bad: Unclear namestype Context = { d1: string; f: boolean; x: number;};
// ✅ Good: Clear namestype Context = { userName: string; hasAcceptedTerms: boolean; signupTimestamp: number;};Context persistence
Section titled “Context persistence”Context is automatically saved when using persistence:
import { createPersister, createLocalStorageStore } from "@useflow/react";
const persister = createPersister({ store: createLocalStorageStore(localStorage, { prefix: "my-app" }),});
<Flow flow={myFlow} initialContext={{}} persister={persister}> {({ renderStep }) => renderStep({ /* ... */ }) }</Flow>;When users return:
- Context is automatically restored
- Flow resumes from last step
- All data is preserved
Learn more about Persistence →
Validation
Section titled “Validation”Validate context before navigation:
function ProfileStep() { const { next } = useFlowState(); const [name, setName] = useState(""); const [email, setEmail] = useState("");
const isValid = () => { return name.length > 0 && email.includes("@") && email.length > 3; };
const handleNext = () => { if (!isValid()) { alert("Please fill all fields correctly"); return; }
next({ name, email }); };
return ( <div> <input value={name} onChange={(e) => setName(e.target.value)} /> <input value={email} onChange={(e) => setEmail(e.target.value)} /> <button onClick={handleNext} disabled={!isValid()}> Continue </button> </div> );}Using validation libraries
Section titled “Using validation libraries”Integrate with Zod, Yup, or other validation libraries:
import { z } from "zod";
const profileSchema = z.object({ name: z.string().min(1, "Name is required"), email: z.string().email("Invalid email"), age: z.number().min(18, "Must be 18 or older"),});
function ProfileStep() { const { next } = useFlowState(); const [data, setData] = useState({ name: "", email: "", age: 0 }); const [errors, setErrors] = useState({});
const handleNext = () => { try { profileSchema.parse(data); next(data); } catch (err) { if (err instanceof z.ZodError) { setErrors(err.formErrors.fieldErrors); } } };
return ( <form onSubmit={(e) => { e.preventDefault(); handleNext(); }} > {/* Form fields with error messages */} <button type="submit">Continue</button> </form> );}Context vs local state
Section titled “Context vs local state”Choose between context and local component state:
| Use Context | Use Local State |
|---|---|
| Data needed in multiple steps | UI state (e.g., open/closed) |
| Should persist when going back | Should reset on revisit |
| Part of the flow’s domain logic | Part of the component’s UI logic |
| Needs to be saved/restored | Temporary interaction state |
function ProfileStep() { const { context, next } = useFlowState();
// ✅ Context: Data needed in other steps // Already in context.name from previous visits
// ✅ Local state: Temporary form state const [nameInput, setNameInput] = useState(context.name || ""); const [isEditing, setIsEditing] = useState(false);
return ( <div> <input value={nameInput} onChange={(e) => setNameInput(e.target.value)} disabled={!isEditing} /> <button onClick={() => setIsEditing(!isEditing)}> {isEditing ? "Cancel" : "Edit"} </button> <button onClick={() => next({ name: nameInput })}>Continue</button> </div> );}Complete example
Section titled “Complete example”Here’s a complete example showing context usage across multiple steps:
import { defineFlow, Flow } from "@useflow/react";import { useState } from "react";
// 1. Define context typetype OnboardingContext = { userId: string; startedAt: number; name?: string; email?: string; userType?: "business" | "personal"; business?: { companyName: string; industry: string; }; preferences?: { theme: "light" | "dark"; notifications: boolean; }; completedAt?: number;};
// 2. Define flowconst onboardingFlow = defineFlow({ id: "onboarding", start: "profile", steps: { profile: { next: "userType" }, userType: { next: ["business", "preferences"] }, business: { next: "preferences" }, preferences: { next: "complete" }, complete: {}, },}).with<OnboardingContext>((steps) => ({ resolvers: { userType: (ctx) => ctx.userType === "business" ? steps.business : steps.preferences, },}));
// 3. Step componentsfunction ProfileStep() { const { next, context } = onboardingFlow.useFlowState({ step: "profile" }); const [name, setName] = useState(context.name || ""); const [email, setEmail] = useState(context.email || "");
return ( <div> <input value={name} onChange={(e) => setName(e.target.value)} /> <input value={email} onChange={(e) => setEmail(e.target.value)} /> <button onClick={() => next({ name, email })}>Continue</button> </div> );}
function UserTypeStep() { const { next } = onboardingFlow.useFlowState({ step: "userType" });
return ( <div> <button onClick={() => next({ userType: "business" })}>Business</button> <button onClick={() => next({ userType: "personal" })}>Personal</button> </div> );}
function BusinessStep() { const { next } = onboardingFlow.useFlowState({ step: "business" }); const [company, setCompany] = useState(""); const [industry, setIndustry] = useState("");
return ( <div> <input value={company} onChange={(e) => setCompany(e.target.value)} /> <input value={industry} onChange={(e) => setIndustry(e.target.value)} /> <button onClick={() => next({ business: { companyName: company, industry }, }) } > Continue </button> </div> );}
function CompleteStep() { const { context } = onboardingFlow.useFlowState({ step: "complete" });
return ( <div> <h1>Welcome, {context.name}!</h1> <p>Email: {context.email}</p> {context.userType === "business" && ( <p>Company: {context.business?.companyName}</p> )} </div> );}
// 4. Render flowfunction App() { return ( <Flow flow={onboardingFlow} initialContext={{ userId: "user-123", startedAt: Date.now(), }} onComplete={({ context }) => { console.log("Completed with:", context); }} > {({ renderStep }) => renderStep({ profile: <ProfileStep />, userType: <UserTypeStep />, business: <BusinessStep />, preferences: <PreferencesStep />, complete: <CompleteStep />, }) } </Flow> );}Next steps
Section titled “Next steps”- Navigation - Control flow movement
- Persistence Guide - Save and restore context
- Branching Flows - Use context for conditional navigation
- Migrations Guide - Handle context schema changes