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.
How persistence works
Section titled “How persistence works”useFlow’s persistence system consists of three layers:
- Store - Low-level interface for reading/writing data (localStorage, database, etc.)
- Persister - Adds features like TTL, versioning, validation, and callbacks
- Flow Component - Uses the persister to auto-save and restore state
// 1. Create a storeimport { createLocalStorageStore, createPersister } from "@useflow/react";
const store = createLocalStorageStore(localStorage);
// 2. Create a persister with featuresconst persister = createPersister({ store, ttl: 7 * 24 * 60 * 60 * 1000, // 7 days});
// 3. Use in Flow component<Flow flow={myFlow} persister={persister} />;Built-in stores
Section titled “Built-in stores”useFlow provides pre-built stores for common environments:
Browser storage (web)
Section titled “Browser storage (web)”import { createLocalStorageStore, createPersister } from "@useflow/react";
// localStorage - Persists across browser sessionsconst localStore = createLocalStorageStore(localStorage);const localPersister = createPersister({ store: localStore });
// sessionStorage - Cleared when tab closesconst sessionStore = createSessionStorageStore(sessionStorage);const sessionPersister = createPersister({ store: sessionStore });Custom prefix:
const store = createLocalStorageStore(localStorage, { prefix: "myapp", // Keys: "myapp:flowId:variantId:instanceId"});Memory store (testing)
Section titled “Memory store (testing)”import { createMemoryStore, createPersister } from "@useflow/react";
// Perfect for tests - state doesn't persist across page reloadsconst store = createMemoryStore();const persister = createPersister({ store });React Native AsyncStorage
Section titled “React Native AsyncStorage”import AsyncStorage from "@react-native-async-storage/async-storage";import { createAsyncStorageStore, createPersister,} from "@useflow/react-native";
const store = createAsyncStorageStore(AsyncStorage);const persister = createPersister({ store });Basic usage
Section titled “Basic usage”Enable persistence
Section titled “Enable persistence”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
Manual save control
Section titled “Manual save control”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).
Save modes
Section titled “Save modes”Control when state is automatically saved using save modes:
<Flow flow={myFlow} persister={persister} saveMode="always" // "always" | "navigation" | "manual">| Mode | Saves On | Use Case |
|---|---|---|
navigation | next/skip/back calls | Default - Balances persistence with performance |
always | Every context update | Real-time autosave, critical flows |
manual | Only when calling save() | Full control, custom save logic |
Examples
Section titled “Examples”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>Persister options
Section titled “Persister options”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"); },});Ttl (time to live)
Section titled “Ttl (time to live)”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 removedValidation
Section titled “Validation”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.
Callbacks
Section titled “Callbacks”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."); },});Flow instances
Section titled “Flow instances”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"Common use cases
Section titled “Common use cases”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> );}Flow variants
Section titled “Flow variants”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.
Custom stores
Section titled “Custom stores”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 });Serialization
Section titled “Serialization”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.
Default serialization
Section titled “Default serialization”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)
Custom serializers
Section titled “Custom serializers”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,});SuperJSON for complex types
Section titled “SuperJSON for complex types”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
Key-value store adapter
Section titled “Key-value store adapter”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}:*`); },});Global configuration
Section titled “Global configuration”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.
Clearing saved state
Section titled “Clearing saved state”Reset flow and clear state
Section titled “Reset flow and clear state”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>;}Programmatic clearing (advanced)
Section titled “Programmatic clearing (advanced)”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 instanceawait 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.
Best practices
Section titled “Best practices”Choose the right save mode
Section titled “Choose the right save mode”| Scenario | Recommended Mode |
|---|---|
| Multi-step form | navigation (default) |
| Long form with autosave | always with debounce |
| Custom save button | manual |
| Critical data entry | always |
Debounce rapid updates
Section titled “Debounce rapid updates”When using saveMode="always", debounce to avoid excessive writes:
<Flow saveMode="always" saveDebounce={500} // Wait 500ms after last change/>Handle restoration state
Section titled “Handle restoration state”Show loading state while restoring:
<Flow flow={myFlow} persister={persister}> {({ isRestoring, renderStep }) => ( <> {isRestoring ? ( <div>Restoring your progress...</div> ) : ( renderStep({ /* steps */ }) )} </> )}</Flow>Validate before restoring
Section titled “Validate before restoring”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; },});Use migrations for schema changes
Section titled “Use migrations for schema changes”When your flow’s context shape changes, use migrations instead of validation:
// See Migration Guide for detailsconst 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.
Secure sensitive data
Section titled “Secure sensitive data”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), }, // ...});Complete example
Section titled “Complete example”View the complete onboarding flow with persistence
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> );}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> );}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> );}Next steps
Section titled “Next steps”- Migrations - Handle schema versioning and breaking changes
- Global Configuration - Configure persistence app-wide
- Testing - Test flows with persistence
- Flow Variants - Persist state per variant