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

Migrations

Migrations allow you to safely update your flow’s structure while preserving user progress. When you make breaking changes to context fields, step names, or flow structure, migrations transform old saved state to work with the new version.

When you persist flow state and later change your flow’s schema, users with old saved state will encounter errors. Migrations solve this by transforming old state to match the new schema:

type OnboardingContext = {
emailAddress: string;
};
const flow = defineFlow({
id: "onboarding",
version: "v2", // Increment version on breaking changes
start: "welcome",
steps: { /* updated steps */ }
}).with<OnboardingContext>(() => ({
migration: (state, fromVersion) => {
// Transform old state to new structure
if (fromVersion === "v1") {
return {
...state,
context: {
...state.context,
emailAddress: state.context.email // Renamed field
}
};
}
return null; // Unknown version, discard
}
}));

You need migrations when making breaking changes to persisted flows:

ChangeNeeds Migration?Why
Add new optional context field❌ NoOld state still valid
Rename context field✅ YesOld state has old field name
Remove required context field✅ YesOld state has extra data
Rename step✅ YesstepId/history contain old name
Remove step✅ YesUser might be on removed step
Change flow structure✅ YesNavigation paths changed
Add new step❌ Usually noDepends on where it’s added
Update step component UI❌ NoUI changes don’t affect state

Rename a context field while preserving data:

// Before (v1)
type ContextV1 = {
email: string;
name: string;
};
// After (v2)
type ContextV2 = {
emailAddress: string; // Renamed from 'email'
fullName: string; // Renamed from 'name'
};
const flow = defineFlow({
id: "onboarding",
version: "v2",
start: "welcome",
steps: {
welcome: { next: "profile" },
profile: { next: "complete" },
complete: {}
}
}).with<ContextV2>(() => ({
migration: (state, fromVersion) => {
if (fromVersion === "v1") {
return {
...state,
context: {
emailAddress: state.context.email,
fullName: state.context.name
}
};
}
return null;
}
}));

Add a new required field with a default value:

// Before (v1)
type ContextV1 = {
name: string;
};
// After (v2)
type ContextV2 = {
name: string;
phoneNumber: string; // New required field
};
const flow = defineFlow({
id: "onboarding",
version: "v2",
start: "welcome",
steps: { /* ... */ }
}).with<MyContext>(() => ({
migration: (state, fromVersion) => {
if (fromVersion === "v1") {
return {
...state,
context: {
...state.context,
phoneNumber: "" // Add with default value
}
};
}
return null;
}
}));

Remove an obsolete field:

// Before (v1)
type ContextV1 = {
name: string;
legacyField: string; // Being removed
};
// After (v2)
type ContextV2 = {
name: string;
};
const flow = defineFlow({
id: "onboarding",
version: "v2",
start: "welcome",
steps: { /* ... */ }
}).with<MyContext>(() => ({
migration: (state, fromVersion) => {
if (fromVersion === "v1") {
const { legacyField, ...rest } = state.context;
return {
...state,
context: rest
};
}
return null;
}
}));

Rename a step and update state references:

const flow = defineFlow({
id: "onboarding",
version: "v2",
start: "welcome",
steps: {
welcome: { next: "profile" }, // Renamed from 'userProfile'
profile: { next: "complete" }, // Renamed from 'userProfile'
complete: {}
}
}).with<MyContext>((steps) => ({
migration: (state, fromVersion) => {
if (fromVersion === "v1") {
return {
...state,
// Update current step if it's the renamed one
stepId: state.stepId === "userProfile" ? steps.profile : state.stepId,
// Update history
history: state.history.map(entry => ({
...entry,
stepId: entry.stepId === "userProfile" ? steps.profile : entry.stepId
})),
// Update path
path: state.path.map(stepId =>
stepId === "userProfile" ? steps.profile : stepId
)
};
}
return null;
}
}));

Handle users who were on a removed step:

const flow = defineFlow({
id: "onboarding",
version: "v2",
start: "welcome",
steps: {
welcome: { next: "profile" },
// Removed: emailVerification step
profile: { next: "complete" },
complete: {}
}
}).with<MyContext>((steps) => ({
migration: (state, fromVersion) => {
if (fromVersion === "v1") {
// If user was on removed step, move them to the next logical step
if (state.stepId === "emailVerification") {
return {
...state,
stepId: steps.profile, // Skip to next step
// Optionally clean history
history: state.history.filter(
entry => entry.stepId !== "emailVerification"
)
};
}
return state;
}
return null;
}
}));

Handle adding a step in the middle of a flow:

// v1: welcome → profile → complete
// v2: welcome → verification → profile → complete
const flow = defineFlow({
id: "onboarding",
version: "v2",
start: "welcome",
steps: {
welcome: { next: "verification" },
verification: { next: "profile" }, // New step
profile: { next: "complete" },
complete: {}
}
}).with<MyContext>(() => ({
migration: (state, fromVersion) => {
if (fromVersion === "v1") {
// Users who haven't reached profile yet should see verification
// Users who passed profile already can skip it
return state; // Usually no change needed
}
return null;
}
}));

Handle multiple version upgrades:

const flow = defineFlow({
id: "onboarding",
version: "v3",
start: "welcome",
steps: { /* ... */ }
}).with<MyContext>(() => ({
migration: (state, fromVersion) => {
let migratedState = state;
// v1 → v2: Rename email field
if (fromVersion === "v1") {
migratedState = {
...migratedState,
context: {
...migratedState.context,
emailAddress: migratedState.context.email
}
};
}
// v2 → v3: Add phone number
if (fromVersion === "v1" || fromVersion === "v2") {
migratedState = {
...migratedState,
context: {
...migratedState.context,
phoneNumber: ""
}
};
}
return migratedState;
}
}));

Apply different migrations based on state:

migrate: (state, fromVersion) => {
if (fromVersion === "v1") {
// Different migration based on user type
if (state.context.accountType === "business") {
return {
...state,
context: {
...state.context,
companyName: state.context.businessName,
companySize: "unknown"
}
};
} else {
return {
...state,
context: {
...state.context,
firstName: state.context.name.split(" ")[0],
lastName: state.context.name.split(" ")[1] || ""
}
};
}
}
return null;
}

Transform data structure while migrating:

// v1: preferences as array
type ContextV1 = {
preferences: string[];
};
// v2: preferences as object
type ContextV2 = {
preferences: {
notifications: boolean;
newsletter: boolean;
updates: boolean;
};
};
migrate: (state, fromVersion) => {
if (fromVersion === "v1") {
const prefArray = state.context.preferences || [];
return {
...state,
context: {
...state.context,
preferences: {
notifications: prefArray.includes("notifications"),
newsletter: prefArray.includes("newsletter"),
updates: prefArray.includes("updates")
}
}
};
}
return null;
}

Return null to discard incompatible state:

migrate: (state, fromVersion) => {
// v1 is too old, can't migrate reliably
if (fromVersion === "v1") {
console.warn("v1 state is too old to migrate, discarding");
return null; // User starts fresh
}
// v2 can be migrated
if (fromVersion === "v2") {
return {
...state,
context: {
...state.context,
newField: "default"
}
};
}
// Unknown version
return null;
}

Test migrations thoroughly:

import { describe, it, expect } from "vitest";
describe("onboarding flow migrations", () => {
it("should migrate v1 to v2", () => {
const oldState = {
stepId: "profile",
context: {
email: "user@example.com",
name: "John Doe"
},
path: ["welcome", "profile"],
history: [
{ stepId: "welcome" },
{ stepId: "profile" }
],
status: "active" as const
};
const migrated = flow.runtimeConfig.migrate?.(oldState, "v1");
expect(migrated).toEqual({
...oldState,
context: {
emailAddress: "user@example.com",
fullName: "John Doe"
}
});
});
it("should discard unknown versions", () => {
const oldState = {
stepId: "welcome",
context: {},
path: [],
history: [],
status: "active" as const
};
const migrated = flow.runtimeConfig.migrate?.(oldState, "v999");
expect(migrated).toBeNull();
});
it("should migrate step renames", () => {
const oldState = {
stepId: "userProfile",
context: {},
path: ["welcome", "userProfile"],
history: [
{ stepId: "welcome" },
{ stepId: "userProfile" }
],
status: "active" as const
};
const migrated = flow.runtimeConfig.migrate?.(oldState, "v1");
expect(migrated?.stepId).toBe("profile");
expect(migrated?.path).toContain("profile");
expect(migrated?.history[1].stepId).toBe("profile");
});
});

Test migrations in the context of the full flow:

import { render, screen, waitFor } from "@testing-library/react";
import { createMemoryStore, createPersister } from "@useflow/react";
it("should restore migrated state", async () => {
const store = createMemoryStore();
const persister = createPersister({ store });
// Save old version state
await persister.save("onboarding", {
stepId: "profile",
context: {
email: "user@example.com" // v1 field name
},
path: ["welcome", "profile"],
history: [],
status: "active",
__meta: {
savedAt: Date.now(),
version: "v1"
}
});
// Render with new version flow
render(
<Flow flow={flow} persister={persister}>
{({ renderStep, context }) => (
<div>
<div data-testid="email">{context.emailAddress}</div>
{renderStep({
welcome: <div>Welcome</div>,
profile: <div>Profile</div>,
complete: <div>Complete</div>
})}
</div>
)}
</Flow>
);
// Should migrate and restore
await waitFor(() => {
expect(screen.getByTestId("email")).toHaveTextContent("user@example.com");
});
});

Update version number for breaking changes:

// ✅ Good: Version incremented
const flow = defineFlow({
version: "v2", // Was "v1"
// ... breaking changes
});
// ❌ Bad: No version change
const flow = defineFlow({
version: "v1", // Still v1 despite breaking changes
// ... breaking changes
});

Use clear version numbers:

// ✅ Good: Clear versioning
version: "v1""v2""v3"
version: "1.0""2.0""3.0"
// ❌ Confusing: Ambiguous
version: "old""new""newer"

Comment your migrations:

migrate: (state, fromVersion) => {
// v1 → v2: Renamed 'email' to 'emailAddress'
// Breaking change: All persisted flows with v1 need migration
if (fromVersion === "v1") {
return {
...state,
context: {
emailAddress: state.context.email
}
};
}
return null;
}

Always handle unexpected versions:

migrate: (state, fromVersion) => {
if (!fromVersion) {
// No version info - very old state
return null;
}
if (fromVersion === "v1") {
// Migrate v1
}
// Unknown future version or very old version
console.warn(`Unknown version: ${fromVersion}`);
return null;
}

Test all migration paths:

describe("migrations", () => {
it("should migrate v1 → v2")
it("should migrate v2 → v3")
it("should migrate v1 → v3 (multi-hop)")
it("should discard unknown versions")
it("should handle missing version")
it("should preserve all state fields")
});

Inform users when migration happens:

<Flow
flow={flow}
persister={persister}
onRestore={(state) => {
if (state.__meta?.version !== flow.config.version) {
toast.info("We've updated! Your progress has been preserved.");
}
}}
>

Don’t immediately delete old field support:

// Support both old and new field names temporarily
const email = context.emailAddress || context.email;

Support multiple versions during transition:

version: "v2.1", // Use minor versions
migrate: (state, fromVersion) => {
// Support v2.0 and v2.1
if (fromVersion?.startsWith("v2")) {
return state; // Compatible
}
// Migrate v1
if (fromVersion === "v1") {
return migrateV1ToV2(state);
}
return null;
}

Use feature flags to test migrations:

const isNewSchemaEnabled = useFeatureFlag("new-schema");
const flow = defineFlow({
version: isNewSchemaEnabled ? "v2" : "v1",
// ...
}).with(() => ({
migration: isNewSchemaEnabled ? migrateV1ToV2 : undefined
}));
View the complete real-world migration example
flow.ts
import { defineFlow } from "@useflow/react";
/**
* Onboarding Flow - Version History
*
* v1 (Initial):
* - Context: { email, name }
* - Steps: welcome → profile → complete
*
* v2 (Renamed fields):
* - Context: { emailAddress, fullName }
* - Steps: welcome → profile → complete
*
* v3 (Added verification):
* - Context: { emailAddress, fullName, phoneNumber }
* - Steps: welcome → verification → profile → complete
*/
export type OnboardingContext = {
emailAddress: string;
fullName: string;
phoneNumber: string;
};
export const onboardingFlow = defineFlow({
id: "onboarding",
version: "v3",
start: "welcome",
steps: {
welcome: { next: "verification" },
verification: { next: "profile" },
profile: { next: "complete" },
complete: {}
}
}).with<OnboardingContext>((steps) => ({
migration: (state, fromVersion) => {
let migratedState = state;
// v1 → v2: Rename fields
if (fromVersion === "v1") {
migratedState = {
...migratedState,
context: {
...migratedState.context,
emailAddress: migratedState.context.email,
fullName: migratedState.context.name
}
};
}
// v2 → v3: Add phone number and verification step
if (fromVersion === "v1" || fromVersion === "v2") {
migratedState = {
...migratedState,
context: {
...migratedState.context,
phoneNumber: ""
},
// If user already completed welcome, they should see verification
stepId: migratedState.stepId === "profile"
? steps.verification
: migratedState.stepId
};
}
return migratedState;
}
}));