rilaykit
Workflow

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
});
OptionTypeDefaultDescription
keyPrefixstring'rilay_workflow_'Namespace prefix for localStorage keys.
compressbooleanfalseBase64-encodes the payload (btoa/atob). An encoding, not real compression.
maxAgenumberundefinedTTL 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

OptionTypeDefaultDescription
autoPersistbooleanfalseSave automatically on meaningful state changes.
debounceMsnumber500Delay before the auto-save fires after the last change.
storageKeystringworkflow IDOverride the storage key.
metadataRecord<string, any>undefinedExtra 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 valuesDate, NaN, ±Infinity, -0, and BigInt are tag-encoded and survive save→load intact. Legacy plain-JSON blobs still load.
  • Auto-save is debounced and skipped while isTransitioning, isSubmitting, or isInitializing; 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_FAILED on persistenceError); 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:

CodeMeaning
SAVE_FAILED / LOAD_FAILED / REMOVE_FAILEDThe corresponding operation failed.
LIST_FAILED / CLEAR_FAILEDListing or clearing keys failed.
QUOTA_EXCEEDEDlocalStorage full; cleanup could not free enough space.
OPERATION_FAILEDGeneric fallback.

On this page