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

Persistence

Persistence allows users to continue their flow where they left off, even after closing the browser or app. useFlow provides a flexible persistence system that works with browser storage, React Native AsyncStorage, custom databases, and more.

useFlow’s persistence system consists of three layers:

  1. Store - Low-level interface for reading/writing data (localStorage, database, etc.)
  2. Persister - Adds features like TTL, versioning, validation, and callbacks
  3. Flow Component - Uses the persister to auto-save and restore state
// 1. Create a store
import { createLocalStorageStore, createPersister } from "@useflow/react";
const store = createLocalStorageStore(localStorage);
// 2. Create a persister with features
const persister = createPersister({
store,
ttl: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// 3. Use in Flow component
<Flow flow={myFlow} persister={persister} />;

useFlow provides pre-built stores for common environments:

import { createLocalStorageStore, createPersister } from "@useflow/react";
// localStorage - Persists across browser sessions
const localStore = createLocalStorageStore(localStorage);
const localPersister = createPersister({ store: localStore });
// sessionStorage - Cleared when tab closes
const sessionStore = createSessionStorageStore(sessionStorage);
const sessionPersister = createPersister({ store: sessionStore });

Custom prefix:

const store = createLocalStorageStore(localStorage, {
prefix: "myapp", // Keys: "myapp:flowId:variantId:instanceId"
});
import { createMemoryStore, createPersister } from "@useflow/react";
// Perfect for tests - state doesn't persist across page reloads
const store = createMemoryStore();
const persister = createPersister({ store });
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
createAsyncStorageStore,
createPersister,
} from "@useflow/react-native";
const store = createAsyncStorageStore(AsyncStorage);
const persister = createPersister({ store });

Pass a persister to the Flow component:

import { Flow } from "@useflow/react";
import { createLocalStorageStore, createPersister } from "@useflow/react";
import { myFlow } from "./flow";
const store = createLocalStorageStore(localStorage);
const persister = createPersister({ store });
function App() {
return (
<Flow
flow={myFlow}
persister={persister}
initialContext={{ name: "" }}
>
{({ renderStep }) => (
// Your steps...
)}
</Flow>
);
}

That’s it! The flow will automatically:

  • Save state on navigation (next/skip/back)
  • Restore state on mount if saved state exists
  • Include context, current step, history, and path in saved state

Control when the flow saves using the save() function:

function MyStep() {
const { save } = flow.useFlowState({ step: "profile" });
const handleChange = async () => {
// Make changes...
await save(); // Manually trigger save
};
return <input onChange={handleChange} />;
}

You can also configure save behavior globally (see Save Modes below).

Control when state is automatically saved using save modes:

<Flow
flow={myFlow}
persister={persister}
saveMode="always" // "always" | "navigation" | "manual"
>
ModeSaves OnUse Case
navigationnext/skip/back callsDefault - Balances persistence with performance
alwaysEvery context updateReal-time autosave, critical flows
manualOnly when calling save()Full control, custom save logic

Always save (real-time autosave):

<Flow
flow={myFlow}
persister={persister}
saveMode="always"
saveDebounce={500} // Debounce rapid updates
>

Manual save only:

<Flow flow={myFlow} persister={persister} saveMode="manual">
{({ save, context }) => (
<div>
{/* Your steps */}
<button onClick={save}>Save Progress</button>
</div>
)}
</Flow>

The createPersister function accepts options for advanced features:

import { createPersister } from "@useflow/react";
const persister = createPersister({
store,
// Time-to-live: Discard state older than 7 days
ttl: 7 * 24 * 60 * 60 * 1000,
// Validate before restoring
validate: (state) => {
return state.stepId !== "invalid" && state.context.email?.includes("@");
},
// Callbacks
onSave: (flowId, state) => {
console.log(`Saved ${flowId} at step ${state.stepId}`);
},
onRestore: (flowId, state) => {
console.log(`Restored ${flowId} from ${state.stepId}`);
analytics.track("flow_restored", { flowId });
},
onError: (error) => {
console.error("Persistence error:", error);
toast.error("Failed to save progress");
},
});

Automatically discard old saved states:

const persister = createPersister({
store,
ttl: 24 * 60 * 60 * 1000, // 24 hours in milliseconds
});
// State older than 24 hours is ignored and removed

Validate saved state before restoring:

const persister = createPersister({
store,
validate: (state) => {
// Only restore if on a valid step
if (state.stepId === "deleted_step") return false;
// Validate context shape
if (!state.context.userId) return false;
return true;
},
});

Important: The validate function should NOT mutate state. For transformations, use migrations.

React to persistence events:

const persister = createPersister({
store,
onSave: (flowId, state) => {
// Track saves
analytics.track("flow_saved", {
flowId,
step: state.stepId,
progress: calculateProgress(state),
});
},
onRestore: (flowId, state) => {
// Notify user
toast.success("Continuing from where you left off!");
},
onError: (error) => {
// Handle errors gracefully
Sentry.captureException(error);
toast.error("Failed to save. Please try again.");
},
});

Persist multiple instances of the same flow using instanceId:

// Different users on same device
<Flow flow={onboardingFlow} instanceId={userId} persister={persister} />
// Each instance has separate saved state
// Keys: "useflow:onboarding:default:user-123"
// "useflow:onboarding:default:user-456"

Multi-tenant applications:

function TenantOnboarding({ tenantId }: { tenantId: string }) {
return (
<Flow flow={onboardingFlow} instanceId={tenantId} persister={persister}>
{/* Each tenant has their own saved progress */}
</Flow>
);
}

Multi-tab support:

function CheckoutFlow() {
const sessionId = useMemo(() => generateId(), []);
return (
<Flow flow={checkoutFlow} instanceId={sessionId} persister={persister}>
{/* Each tab has its own checkout flow */}
</Flow>
);
}

Persist state separately for different flow variants:

import { standardFlow, expressFlow } from "./flows";
const selectedFlow = useExpress ? expressFlow : standardFlow;
<Flow flow={selectedFlow} persister={persister} />;
// Keys: "useflow:onboarding:standard:default"
// "useflow:onboarding:express:default"

Each variant stores state independently. See Flow Variants guide for more.

Create custom stores for databases, APIs, or cloud storage:

import type { FlowStore } from "@useflow/react";
export function createDatabaseStore(db: Database): FlowStore {
return {
async get(flowId, options) {
const { instanceId = "default", variantId = "default" } = options || {};
const result = await db.query(
"SELECT state FROM flows WHERE flowId = ? AND instanceId = ? AND variantId = ?",
[flowId, instanceId, variantId]
);
return result ? JSON.parse(result.state) : null;
},
async set(flowId, state, options) {
const { instanceId = "default", variantId = "default" } = options || {};
await db.query(
"INSERT OR REPLACE INTO flows (flowId, instanceId, variantId, state) VALUES (?, ?, ?, ?)",
[flowId, instanceId, variantId, JSON.stringify(state)]
);
},
async remove(flowId, options) {
const { instanceId = "default", variantId = "default" } = options || {};
await db.query(
"DELETE FROM flows WHERE flowId = ? AND instanceId = ? AND variantId = ?",
[flowId, instanceId, variantId]
);
},
// Optional: Bulk operations
async removeFlow(flowId) {
await db.query("DELETE FROM flows WHERE flowId = ?", [flowId]);
},
async removeAll() {
await db.query("DELETE FROM flows");
},
async list(flowId) {
const results = await db.query(
"SELECT instanceId, variantId, state FROM flows WHERE flowId = ?",
[flowId]
);
return results.map((row) => ({
flowId,
instanceId: row.instanceId,
variantId: row.variantId,
state: JSON.parse(row.state),
}));
},
};
}

Use the custom store:

import { createPersister } from "@useflow/react";
import { createDatabaseStore } from "./stores/database";
const store = createDatabaseStore(myDatabase);
const persister = createPersister({ store });

Flow state is serialized to JSON for storage. The default serializer handles most common types, but you may need a custom serializer for complex data.

The default JsonSerializer uses JSON.stringify() and JSON.parse():

import { createPersister, JsonSerializer } from "@useflow/react";
const persister = createPersister({
store,
serializer: JsonSerializer, // This is the default
});

What works:

  • Primitives (string, number, boolean, null)
  • Objects and arrays
  • Nested structures

What doesn’t work:

  • Dates (serialized as strings)
  • Maps, Sets (lost during serialization)
  • Functions (stripped out)
  • Class instances (lose their methods)
  • Circular references (throws error)

Create custom serializers for complex data types:

import type { FlowSerializer } from "@useflow/react";
const customSerializer: FlowSerializer = {
serialize: (state) => {
// Transform before stringifying
const transformed = {
...state,
context: {
...state.context,
// Convert Date to ISO string
createdAt: state.context.createdAt?.toISOString(),
// Convert Map to array
preferences: state.context.preferences
? Array.from(state.context.preferences.entries())
: null,
},
};
return JSON.stringify(transformed);
},
deserialize: (data) => {
const parsed = JSON.parse(data);
// Transform back to original types
return {
...parsed,
context: {
...parsed.context,
// Restore Date
createdAt: parsed.context.createdAt
? new Date(parsed.context.createdAt)
: undefined,
// Restore Map
preferences: parsed.context.preferences
? new Map(parsed.context.preferences)
: undefined,
},
};
},
};
const persister = createPersister({
store,
serializer: customSerializer,
});

For automatic handling of complex types, use SuperJSON:

import SuperJSON from "superjson";
import type { FlowSerializer } from "@useflow/react";
const superJsonSerializer: FlowSerializer = {
serialize: (state) => SuperJSON.stringify(state),
deserialize: (data) => SuperJSON.parse(data),
};
const persister = createPersister({
store,
serializer: superJsonSerializer,
});

SuperJSON automatically handles:

  • Dates
  • RegExp
  • Maps, Sets
  • BigInt
  • Typed arrays
  • And more

For simple key-value stores (Redis, MMKV, etc.), use the KV adapter:

import { kvStorageAdapter } from "@useflow/react";
const store = kvStorageAdapter({
storage: {
getItem: (key) => redisClient.get(key),
setItem: (key, value) => redisClient.set(key, value),
removeItem: (key) => redisClient.del(key),
},
serializer: JsonSerializer,
formatKey: (flowId, instanceId, variantId) => {
const vid = variantId || "default";
const iid = instanceId || "default";
return `flow:${flowId}:${vid}:${iid}`;
},
listKeys: (flowId) => {
// Return all keys for this flow (for removeFlow/list operations)
return redisClient.keys(`flow:${flowId}:*`);
},
});

Configure persistence globally with FlowProvider:

import { FlowProvider } from "@useflow/react";
import { createLocalStorageStore, createPersister } from "@useflow/react";
const store = createLocalStorageStore(localStorage);
const persister = createPersister({ store });
function App() {
return (
<FlowProvider
config={{
persister,
saveMode: "always",
saveDebounce: 500,
onPersistenceError: (error) => {
Sentry.captureException(error);
toast.error("Failed to save progress");
},
}}
>
<YourApp />
</FlowProvider>
);
}

Individual flows can override these defaults:

<Flow
flow={myFlow}
saveMode="manual" // Overrides global "always"
/>

See Global Configuration for more.

The reset() function automatically clears the persisted state:

function MyFlow() {
const { reset } = useFlowState();
const handleClearProgress = () => {
reset(); // Resets flow to start AND clears saved state
};
return <button onClick={handleClearProgress}>Start Over</button>;
}

If you need to clear state without resetting the current flow, you can access the persister directly:

import { createLocalStorageStore, createPersister } from "@useflow/react";
const store = createLocalStorageStore(localStorage);
const persister = createPersister({ store });
// Remove a specific flow instance
await persister.remove?.("onboarding", {
instanceId: "user-123",
variantId: "express",
});
// Remove all instances of a specific flow (if supported by store)
await persister.removeFlow?.("onboarding");
// Clear all flow state from the store (if supported by store)
await persister.removeAll?.();

Note: Not all storage implementations support removeFlow and removeAll. Check the specific store documentation.

ScenarioRecommended Mode
Multi-step formnavigation (default)
Long form with autosavealways with debounce
Custom save buttonmanual
Critical data entryalways

When using saveMode="always", debounce to avoid excessive writes:

<Flow
saveMode="always"
saveDebounce={500} // Wait 500ms after last change
/>

Show loading state while restoring:

<Flow flow={myFlow} persister={persister}>
{({ isRestoring, renderStep }) => (
<>
{isRestoring ? (
<div>Restoring your progress...</div>
) : (
renderStep({
/* steps */
})
)}
</>
)}
</Flow>

Prevent broken states from being restored:

const persister = createPersister({
store,
validate: (state) => {
// Check step exists
if (!validSteps.includes(state.stepId)) return false;
// Check required context
if (!state.context.userId) return false;
// Check version compatibility
if (state.__meta?.version && state.__meta.version < "2.0.0") {
return false; // Too old, use migration instead
}
return true;
},
});

When your flow’s context shape changes, use migrations instead of validation:

// See Migration Guide for details
const flow = defineFlow({
id: "onboarding",
version: "2.0.0",
start: "welcome",
steps: {
// ...
}
}).with<MyContext>(() => ({
migration: (state, fromVersion) => {
if (!fromVersion || fromVersion < "2.0.0") {
// Transform old state to new shape
return {
...state,
context: {
...state.context,
newField: "default",
},
};
}
return state;
},
}));

See Migrations Guide for comprehensive migration examples.

Don’t store sensitive data in persistence:

const persister = createPersister({
store,
onSave: (flowId, state) => {
// Warning: localStorage is not encrypted
if (state.context.creditCard) {
console.warn("Sensitive data in persisted state!");
}
},
});

Consider encrypting sensitive data before saving:

const store = kvStorageAdapter({
storage: {
getItem: (key) => {
const encrypted = localStorage.getItem(key);
return encrypted ? decrypt(encrypted) : null;
},
setItem: (key, value) => {
localStorage.setItem(key, encrypt(value));
},
removeItem: (key) => localStorage.removeItem(key),
},
// ...
});
View the complete onboarding flow with persistence
App.tsx
import { FlowProvider } from "@useflow/react";
import { createLocalStorageStore, createPersister } from "@useflow/react";
const store = createLocalStorageStore(localStorage, { prefix: "myapp" });
const persister = createPersister({
store,
ttl: 30 * 24 * 60 * 60 * 1000, // 30 days
validate: (state) => {
// Only restore if user hasn't completed
return state.status !== "completed";
},
onRestore: (flowId, state) => {
toast.info(`Continuing from step: ${state.stepId}`);
},
onError: (error) => {
console.error("Persistence failed:", error);
},
});
export function App() {
return (
<FlowProvider
config={{
persister,
saveMode: "navigation",
saveDebounce: 300,
}}
>
<OnboardingFlow />
</FlowProvider>
);
}
OnboardingFlow.tsx
import { Flow } from "@useflow/react";
import { onboardingFlow } from "./flow";
export function OnboardingFlow() {
return (
<Flow
flow={onboardingFlow}
initialContext={{
name: "",
email: "",
preferences: [],
}}
>
{({ renderStep, remove, isRestoring }) => (
<div>
{isRestoring && <div>Loading saved progress...</div>}
{renderStep({
welcome: <WelcomeStep />,
profile: <ProfileStep />,
preferences: <PreferencesStep />,
complete: <CompleteStep />,
})}
<button onClick={remove}>Start Over</button>
</div>
)}
</Flow>
);
}
ProfileStep.tsx
export function ProfileStep() {
const { context, setContext, next, save } = onboardingFlow.useFlowState({
step: "profile",
});
const handleSaveDraft = async () => {
await save(); // Manual save
toast.success("Draft saved!");
};
return (
<div>
<input
value={context.name}
onChange={(e) => setContext({ name: e.target.value })}
placeholder="Name"
/>
<input
value={context.email}
onChange={(e) => setContext({ email: e.target.value })}
placeholder="Email"
/>
<button onClick={handleSaveDraft}>Save Draft</button>
<button onClick={() => next()}>Continue</button>
</div>
);
}