Persistence
Save and restore workflow progress with built-in localStorage adapter or custom storage.
Persistence saves workflow progress automatically and restores it when the user returns. Configure an adapter on the flow builder; WorkflowProvider loads saved state on mount and, with autoPersist: true, auto-saves on changes (debounced).
LocalStorageAdapter
Built-in browser adapter. SSR-safe: all operations no-op when window is unavailable.
import { LocalStorageAdapter } from '@rilaykit/workflow';
const adapter = new LocalStorageAdapter({
keyPrefix: 'rilay_workflow_', // default
compress: false, // base64-encode payload when true
maxAge: undefined, // ms before data expires
});| Option | Type | Default | Description |
|---|---|---|---|
keyPrefix | string | 'rilay_workflow_' | Namespace prefix for localStorage keys. |
compress | boolean | false | Base64-encodes the payload (btoa/atob). An encoding, not real compression. |
maxAge | number | undefined | TTL in ms. Expired data is removed on next load/exists. |
Methods: save, load, remove, exists, plus optional listKeys and clear. On QuotaExceededError, save clears expired entries and retries once.
Configuration
Enable persistence via .configure():
import { LocalStorageAdapter } from '@rilaykit/workflow';
const workflow = flow.create(rilay, 'onboarding', 'Onboarding')
.step({ id: 'personal-info', title: 'Personal Info', formConfig: personalInfoForm })
.step({ id: 'preferences', title: 'Preferences', formConfig: preferencesForm })
.configure({
persistence: {
adapter: new LocalStorageAdapter({ maxAge: 7 * 24 * 60 * 60 * 1000 }),
options: { autoPersist: true, debounceMs: 500 },
userId: currentUser.id, // prefixes the storage key (userId:workflowId): per-user isolation
},
});PersistenceOptions
| Option | Type | Default | Description |
|---|---|---|---|
autoPersist | boolean | false | Save automatically on meaningful state changes. |
debounceMs | number | 500 | Delay before the auto-save fires after the last change. |
storageKey | string | workflow ID | Override the storage key. |
metadata | Record<string, any> | undefined | Extra metadata saved alongside workflow data. |
PersistedWorkflowData
interface PersistedWorkflowData {
workflowId: string;
currentStepIndex: number;
allData: Record<string, any>;
stepData: Record<string, any>;
repeatableOrders?: Record<string, Record<string, string[]>>; // row order per step
visitedSteps: string[];
passedSteps?: string[];
skippedSteps?: string[]; // skipped steps stay out of the completion payload after reload
lastSaved: number; // Unix timestamp
metadata?: Record<string, any>;
}Save/restore guarantees
- Byte-faithful values —
Date,NaN,±Infinity,-0, andBigIntare tag-encoded and survive save→load intact. Legacy plain-JSON blobs still load. - Auto-save is debounced and skipped while
isTransitioning,isSubmitting, orisInitializing; only meaningful changes (step index, data, visited steps) trigger a save. - Unmount flushes any pending debounced save — the last edit is never lost.
- Completion clears persisted data; a late in-flight save cannot resurrect it.
- Corrupted blobs degrade to a fresh start (surfaced as
LOAD_FAILEDonpersistenceError); an out-of-range step index clamps. - Resume into a now-hidden step relocates forward to the next visible step.
Accessing persistence state
useFlow() (from @rilaykit/workflow/react) exposes persistNow, isPersisting, and persistenceError:
import { useFlow } from '@rilaykit/workflow/react';
function PersistenceIndicator() {
const { isPersisting, persistenceError, persistNow } = useFlow();
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{isPersisting && <span>Saving...</span>}
{persistenceError && <span className="text-destructive">Save failed: {persistenceError.message}</span>}
<button onClick={() => persistNow?.()}>Save now</button>
</div>
);
}Custom adapter
Implement WorkflowPersistenceAdapter for any backend:
interface WorkflowPersistenceAdapter {
save(key: string, data: PersistedWorkflowData): Promise<void>;
load(key: string): Promise<PersistedWorkflowData | null>;
remove(key: string): Promise<void>;
exists(key: string): Promise<boolean>;
listKeys?(): Promise<string[]>; // optional, admin/cleanup
clear?(): Promise<void>; // optional
}import type { PersistedWorkflowData, WorkflowPersistenceAdapter } from '@rilaykit/workflow';
import { supabase } from '@/lib/supabase';
export class SupabaseAdapter implements WorkflowPersistenceAdapter {
private table = 'workflow_progress';
async save(key: string, data: PersistedWorkflowData): Promise<void> {
const { error } = await supabase
.from(this.table)
.upsert({ key, data: JSON.stringify(data), updated_at: new Date().toISOString() }, { onConflict: 'key' });
if (error) throw new Error(error.message);
}
async load(key: string): Promise<PersistedWorkflowData | null> {
const { data, error } = await supabase.from(this.table).select('data').eq('key', key).single();
if (error || !data) return null;
return JSON.parse(data.data);
}
async remove(key: string): Promise<void> {
await supabase.from(this.table).delete().eq('key', key);
}
async exists(key: string): Promise<boolean> {
const { count } = await supabase
.from(this.table)
.select('key', { count: 'exact', head: true })
.eq('key', key);
return (count ?? 0) > 0;
}
}Error handling
Failures are wrapped in WorkflowPersistenceError with a machine-readable code, surfaced on persistenceError, and routed to analytics.onError and the monitoring adapter:
| Code | Meaning |
|---|---|
SAVE_FAILED / LOAD_FAILED / REMOVE_FAILED | The corresponding operation failed. |
LIST_FAILED / CLEAR_FAILED | Listing or clearing keys failed. |
QUOTA_EXCEEDED | localStorage full; cleanup could not free enough space. |
OPERATION_FAILED | Generic fallback. |