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

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.

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;
};

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: {},
},
});

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;
};

Reuse common fields across flows:

// Base context shared across all flows
type BaseContext = {
userId: string;
startedAt: number;
completedAt?: number;
};
// Specific flow contexts extend the base
type OnboardingContext = BaseContext & {
name: string;
email: string;
};
type CheckoutContext = BaseContext & {
cartItems: CartItem[];
shippingAddress: Address;
paymentMethod: PaymentMethod;
};

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>;
}

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
}

There are three ways to update context when navigating:

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>;
}

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>;
}

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>;
}

Build up context across multiple steps:

// Step 1: Basic info
function Step1() {
const { next } = useFlowState();
return (
<button onClick={() => next({ name: "Alice", email: "alice@example.com" })}>
Continue
</button>
);
}
// Step 2: Add preferences
function 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 context
function Step3() {
const { context } = useFlowState();
// context = {
// name: 'Alice',
// email: 'alice@example.com',
// theme: 'dark',
// notifications: true
// }
}

Set initial values when mounting the flow:

<Flow
flow={myFlow}
initialContext={{
userId: user.id,
startedAt: Date.now(),
theme: "light",
notifications: false,
}}
>
{({ renderStep }) =>
renderStep({
/* ... */
})
}
</Flow>

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>
);
}

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>;
}

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>;
}

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>

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;
};
};

Use spread operators to avoid mutating nested objects:

// ❌ Bad: Mutating nested object
next((ctx) => {
ctx.preferences.theme = "dark"; // Mutation!
return ctx;
});
// ✅ Good: Create new nested object
next((ctx) => ({
...ctx,
preferences: {
...ctx.preferences,
theme: "dark",
},
}));

Avoid storing non-serializable values (functions, class instances, etc.):

// ❌ Bad: Non-serializable
type Context = {
name: string;
validate: () => boolean; // Function - can't be saved
createdAt: Date; // Date object - loses type when saved
};
// ✅ Good: Serializable
type Context = {
name: string;
createdAt: number; // Timestamp instead of Date
};

Choose clear, descriptive names:

// ❌ Bad: Unclear names
type Context = {
d1: string;
f: boolean;
x: number;
};
// ✅ Good: Clear names
type Context = {
userName: string;
hasAcceptedTerms: boolean;
signupTimestamp: number;
};

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 →

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>
);
}

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>
);
}

Choose between context and local component state:

Use ContextUse Local State
Data needed in multiple stepsUI state (e.g., open/closed)
Should persist when going backShould reset on revisit
Part of the flow’s domain logicPart of the component’s UI logic
Needs to be saved/restoredTemporary 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>
);
}

Here’s a complete example showing context usage across multiple steps:

import { defineFlow, Flow } from "@useflow/react";
import { useState } from "react";
// 1. Define context type
type 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 flow
const 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 components
function 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 flow
function 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>
);
}