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

Type Safety

useFlow provides exceptional type safety that catches errors at compile time, not runtime. Entire classes of bugs become impossible.

useFlow leverages TypeScript to make these guarantees:

  1. Enforced component mapping - Cannot forget to provide a component for any step
  2. Step-specific navigation - Each step knows exactly which steps it can navigate to
  3. Type-safe resolvers - Context-driven navigation can only route to valid steps
  4. Automatic context typing - Your context type flows through all components automatically
  5. Compile-time validation - All of this is checked when you write code, not when users click

The result: Navigation bugs, missing components, and invalid context updates literally cannot compile. Refactoring is safe because TypeScript tells you exactly what needs updating.

TypeScript requires you to provide components for every step:

const myFlow = defineFlow({
id: "checkout",
start: "cart",
steps: {
cart: { next: "shipping" },
shipping: { next: "payment" },
payment: { next: "confirm" },
confirm: {}
}
});
// In your component
<Flow flow={myFlow}>
{({ renderStep }) => renderStep({
cart: <CartStep />,
shipping: <ShippingStep />,
// ❌ TypeScript Error: Missing properties 'payment', 'confirm'
})}
</Flow>
// ✅ Must provide ALL steps
{({ renderStep }) => renderStep({
cart: <CartStep />,
shipping: <ShippingStep />,
payment: <PaymentStep />,
confirm: <ConfirmStep />
})}

Each step knows exactly which steps it can navigate to. TypeScript enforces this at compile time.

There are two ways to control navigation:

You control navigation in your components. When you define multiple next options, TypeScript enforces which steps are valid:

type SurveyContext = {
score?: number;
};
const surveyFlow = defineFlow({
id: "survey",
start: "intro",
steps: {
intro: { next: "question1" },
question1: { next: ["question2", "results"] }, // Can go to 2 places
question2: { next: "results" },
results: {}
}
}).with<SurveyContext>();
function Question1() {
// Use the flow-specific hook with step parameter
const { next, context } = surveyFlow.useFlowState({ step: "question1" });
const handleNext = () => {
// Decide where to go based on logic
if (context.score && context.score > 80) {
next("results"); // ✅ Valid - skip question2
} else {
next("question2"); // ✅ Valid - continue to next question
}
// TypeScript prevents invalid navigation
next("intro"); // ❌ Error: Argument not assignable to '"question2" | "results"'
next("invalid"); // ❌ Error: Not a valid next step
};
return <button onClick={handleNext}>Next</button>;
}

Navigation is controlled automatically by context via resolvers.

type UserFlowContext = {
userType?: "business" | "personal";
isVerified?: boolean;
};
const userFlow = defineFlow({
id: "user-flow",
start: "choose",
steps: {
choose: { next: ["business", "personal"] },
business: { next: ["verify", "complete"] },
personal: { next: "complete" },
verify: { next: "complete" },
complete: {}
}
}).with<UserFlowContext>((steps) => ({
resolvers: {
// Resolvers are type-safe - can only return steps from the 'next' array
choose: (ctx) =>
ctx.userType === "business"
? steps.business // ✅ Valid - in next: ["business", "personal"]
: steps.personal, // ✅ Valid
// steps.verify would be ❌ Error - not in next array
business: (ctx) =>
ctx.isVerified
? steps.complete // ✅ Valid - in next: ["verify", "complete"]
: steps.verify // ✅ Valid
// steps.personal would be ❌ Error - not in next array
}
}));
function ChooseStep() {
const { setContext, next } = userFlow.useFlowState({ step: "choose" });
// Call next() without arguments - resolver handles the navigation
return (
<button onClick={() => {
setContext({ userType: "business" });
next(); // Automatically goes to correct step based on context
}}>
Business Account
</button>
);
}
function BusinessStep() {
const { context, next } = userFlow.useFlowState({ step: "business" });
// You can combine both approaches:
// 1. Call next() without args - uses resolver (automatic)
next(); // Uses resolver based on context.isVerified
// 2. Call next(target) - overrides resolver (manual)
next("verify"); // ✅ Ignores resolver, goes directly to verify
next("complete"); // ✅ Ignores resolver, goes directly to complete
next("personal"); // ❌ Error: Not a valid next step from business
}

When using .with<Context>(), your context is automatically typed in all components:

type CheckoutContext = {
items: CartItem[];
total: number;
};
const checkoutFlow = defineFlow({
id: "checkout",
start: "cart",
steps: {
cart: { next: "shipping" },
shipping: { next: "payment" },
payment: {}
}
}).with<CheckoutContext>();
function ShippingStep() {
const { context, setContext } = checkoutFlow.useFlowState({ step: "shipping" });
console.log(context.total); // ✅ number
setContext({ total: 99.99 }); // ✅ Valid
setContext({ total: "99.99" }); // ❌ Error: Type 'string' not assignable
setContext({ invalid: true }); // ❌ Error: Property doesn't exist
}
Section titled “Recommended: Flow hook with automatic context typing”

The best approach uses .with<Context>() to bind your context type to the flow, giving you automatic context typing and step-specific navigation:

import { defineFlow } from "@useflow/react";
// 1. Define your context type
type OnboardingContext = {
email: string;
name: string;
preferences?: {
theme: "light" | "dark";
notifications: boolean;
};
};
// 2. Create your flow with context type
export const onboardingFlow = defineFlow({
id: "onboarding",
start: "email",
steps: {
email: { next: "profile" },
profile: { next: "preferences" },
preferences: { next: "complete" },
complete: {}
}
}).with<OnboardingContext>();
// 3. Use the flow's hook - context is automatically typed!
function ProfileStep() {
// ✅ Context is automatically OnboardingContext
// ✅ Step-specific navigation types
const { context, setContext, next } = onboardingFlow.useFlowState({ step: "profile" });
return (
<div>
<input
value={context.name} // Already typed as string
onChange={(e) => setContext({ name: e.target.value })}
/>
<button onClick={() => next()}>Continue</button>
</div>
);
}

If you prefer, you can use the generic useFlowState hook and specify the context type manually:

import { defineFlow, useFlowState } from "@useflow/react";
type OnboardingContext = {
email: string;
name: string;
};
// Flow without .with<Context>()
export const onboardingFlow = defineFlow({
id: "onboarding",
start: "email",
steps: {
email: { next: "profile" },
profile: { next: "complete" },
complete: {}
}
});
function ProfileStep() {
// ⚠️ Must specify context type on every usage
// ⚠️ No step-specific navigation types
const { context, setContext, next } = useFlowState<OnboardingContext>();
return (
<div>
<input
value={context.name}
onChange={(e) => setContext({ name: e.target.value })}
/>
<button onClick={() => next()}>Continue</button>
</div>
);
}

1. Always use the step parameter for navigation type safety

Section titled “1. Always use the step parameter for navigation type safety”
// ✅ Best: Step-specific navigation types
function MyStep() {
const { next } = myFlow.useFlowState({ step: "currentStep" });
// TypeScript knows valid next steps for THIS specific step
}
// ⚠️ Less type safety: No step-specific validation
function MyStep() {
const { next } = myFlow.useFlowState();
}
type Context = {
// Required from start
email: string;
// Added in later steps
name?: string;
age?: number;
};
// Initial context only needs required fields
<Flow
flow={myFlow}
initialContext={{ email: "" }} // ✅ Valid, optional fields can be omitted
/>
type CheckoutContext =
| { type: "guest"; email: string }
| { type: "member"; userId: string; savedCards: Card[] };
function PaymentStep() {
const { context } = useFlowState<CheckoutContext>();
if (context.type === "guest") {
// TypeScript knows: context.email exists, context.userId doesn't
return <GuestCheckout email={context.email} />;
} else {
// TypeScript knows: context.userId and savedCards exist
return <MemberCheckout cards={context.savedCards} />;
}
}
// Flow definition
profile: { next: "preferences" }
// Error in component
next("welcome"); // ❌ Can't go backward with next()
// Solution: Use back() or add to next array
back(); // ✅ Goes to previous step

You haven’t provided components for all steps:

// Check your flow definition for ALL steps
const flow = defineFlow({
steps: {
a: { next: "b" },
b: { next: "c" },
c: {} // Don't forget terminal steps!
}
});
// Must provide all three
renderStep({
a: <A />,
b: <B />,
c: <C /> // Including terminal step
});