Save and resume in-progress forms.
FieldCraft can auto-save form progress to localStorage, a server-side adapter, or both. When a user returns, the engine restores their values, current section, and visited sections from the saved snapshot.
Enable Drafts
Set allowDraftSave in your schema settings:
const schema: FormEngineSchema = {
id: "application",
version: "1.0.0",
title: "Application Form",
submitAction: { type: "callback" },
settings: {
allowDraftSave: true,
draftStorage: "local", // "local" | "server" | "both"
draftTtlHours: 168, // 7 days (default: 72 hours)
},
sections: [/* ... */],
};
Storage Options
| Mode | Description |
|---|
"local" | localStorage only. Fast, no server needed. Data stays on-device. |
"server" | Server-side only via a DraftAdapter. Works across devices. |
"both" | Saves to both. On load, checks localStorage first, then server. |
What Gets Saved
A draft snapshot contains:
type DraftSnapshot = {
values: Record<string, unknown>; // All field values
currentSectionId: string; // Where the user left off
visitedSectionIds: string[]; // Navigation history
savedAt: string; // ISO 8601 timestamp
};
Engine API
const engine = createEngine(schema);
// Save current state as a draft
await engine.saveDraft();
// Load a previously saved draft (returns true if found)
const restored = await engine.loadDraft();
// Delete the saved draft
engine.clearDraft();
// Check draft state
const state = engine.getState();
state.hasDraft; // boolean
state.lastDraftSavedAt; // string | undefined (ISO 8601)
TTL (Time-to-Live)
Drafts expire after draftTtlHours (default: 72 hours). On load, the engine checks whether the draft has expired. Expired drafts are discarded and loadDraft() returns false.
settings: {
allowDraftSave: true,
draftTtlHours: 24, // Expire after 1 day
}
LocalStorage Keys
When using "local" storage, drafts are stored under the key pattern fe_draft__{schemaId}__{sessionToken}. The sessionToken is passed when creating the engine:
const engine = createEngine(schema, {
sessionToken: "user-abc-123",
});
// Stored as: fe_draft__application__user-abc-123
Server-Side Drafts
For cross-device persistence, implement a DraftAdapter and pass it when creating the engine:
import type { DraftAdapter, DraftData } from "@squaredr/fieldcraft-core";
const myAdapter: DraftAdapter = {
async save(draft: DraftData) {
await fetch("/api/drafts", {
method: "POST",
body: JSON.stringify(draft),
});
},
async load(schemaId: string, sessionToken: string) {
const res = await fetch(`/api/drafts/${schemaId}/${sessionToken}`);
if (!res.ok) return null;
return res.json();
},
async delete(schemaId: string, sessionToken: string) {
await fetch(`/api/drafts/${schemaId}/${sessionToken}`, {
method: "DELETE",
});
},
};
const engine = createEngine(schema, {
sessionToken: "user-abc-123",
draftAdapter: myAdapter,
});
DraftData Shape
type DraftData = {
schemaId: string;
sessionToken: string;
partialData: Record<string, unknown>;
currentSectionId?: string;
visitedSectionIds?: string[];
savedAt: string; // ISO 8601
expiresAt: string; // ISO 8601
};
Built-in Adapters
The storage adapter packages include draft support out of the box:
| Package | Draft Support |
|---|
@squaredr/fieldcraft-supabase | Upserts to a drafts table with RLS |
@squaredr/fieldcraft-postgres | Upserts via Drizzle ORM with TTL column |
@squaredr/fieldcraft-webhook | Not applicable (webhooks are fire-and-forget) |
import { createSupabaseAdapter } from "@squaredr/fieldcraft-supabase";
const adapter = createSupabaseAdapter({
supabaseUrl: process.env.SUPABASE_URL!,
supabaseKey: process.env.SUPABASE_ANON_KEY!,
});
const engine = createEngine(schema, {
sessionToken: "user-abc-123",
draftAdapter: adapter.draftAdapter,
adapters: [adapter],
});
Resume Prompt
When loadDraft() finds a valid draft, you can show a prompt letting the user choose to resume or start fresh:
function MyForm({ schema }: { schema: FormEngineSchema }) {
const [engine] = useState(() => createEngine(schema, { sessionToken: "user-123" }));
const [showResume, setShowResume] = useState(false);
useEffect(() => {
engine.loadDraft().then((found) => {
if (found) setShowResume(true);
});
}, [engine]);
if (showResume) {
return (
<div>
<p>You have a saved draft. Resume where you left off?</p>
<button onClick={() => setShowResume(false)}>Resume</button>
<button onClick={() => { engine.clearDraft(); setShowResume(false); }}>
Start Over
</button>
</div>
);
}
return <FormEngineRenderer engine={engine} />;
}