Users abandon long forms. Network drops. Browsers crash. Form persistence lets you save work-in-progress and restore it later — so users never lose data. This page covers client-side and server-side persistence patterns with FormEngine.
Client-side: localStorage
The simplest approach — save form data to localStorage on every change and load it on mount:
const STORAGE_KEY = 'myform-draft'
const loadDraft = (): Record<string, unknown> | undefined => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : undefined
} catch {
return undefined
}
}
const saveDraft = debounce((data: Record<string, unknown>) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
}, 500)
const clearDraft = () => localStorage.removeItem(STORAGE_KEY)
export default function PersistentForm() {
const [initialData] = useState(loadDraft)
return (
<FormViewer
view={view}
getForm={getForm}
initialData={initialData}
onFormDataChange={(fd) => saveDraft(fd.data)}
/>
)
}
After successful submit, call clearDraft() to remove the saved state.
FormEngine Designer provides IFormStorage for saving form schemas. You can adapt the same approach for form data using IndexedDB directly:
import { openDB } from 'idb'
const db = openDB('formengine-drafts', 1, {
upgrade(db) {
db.createObjectStore('drafts')
},
})
async function saveDraft(formName: string, data: unknown) {
(await db).put('drafts', data, formName)
}
async function loadDraft(formName: string) {
return (await db).get('drafts', formName)
}
async function clearDraft(formName: string) {
(await db).delete('drafts', formName)
}
IndexedDB handles larger payloads than localStorage (which is limited to ~5MB per origin) and doesn’t block the main thread.
Server-side persistence
For multi-device recovery and compliance-sensitive forms, save drafts to your backend:
const saveDraft = debounce(async (data: Record<string, unknown>) => {
await fetch('/api/forms/draft', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ formId: 'onboarding', data }),
})
}, 1000)
const loadDraft = async (): Promise<Record<string, unknown> | undefined> => {
const res = await fetch('/api/forms/draft?formId=onboarding')
if (!res.ok) return undefined
const body = await res.json()
return body.data
}
Use a longer debounce interval (1-2s) for server saves to reduce API load.
Auto-save indicator
Show users that their work is being saved:
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
const saveDraft = debounce(async (data: Record<string, unknown>) => {
setSaveStatus('saving')
try {
await persistToBackend(data)
setSaveStatus('saved')
} catch {
setSaveStatus('error')
}
}, 1000)
Render the indicator:
<span>
{saveStatus === 'saving' && 'Saving…'}
{saveStatus === 'saved' && 'All changes saved'}
{saveStatus === 'error' && 'Failed to save — will retry'}
</span>
Restoring with confirmation
When a user returns and a draft exists, confirm before overwriting any new data:
const [initialData, setInitialData] = useState<Record<string, unknown>>({})
const [showRestore, setShowRestore] = useState(false)
useEffect(() => {
const draft = loadDraft()
if (draft) setShowRestore(true)
}, [])
const handleRestore = () => {
setInitialData(loadDraft()!)
setShowRestore(false)
}
const handleDiscard = () => {
clearDraft()
setShowRestore(false)
}
{showRestore && (
<div role="alert">
You have unsaved changes from a previous session.
<button onClick={handleRestore}>Restore draft</button>
<button onClick={handleDiscard}>Start fresh</button>
</div>
)}
For wizard/multi-step forms, save after each step transition:
const handleStepChange = (from: number, to: number) => {
const data = viewerRef.current?.formData.data
saveDraft({ ...data, _lastStep: to })
}
On restore, navigate to the saved step:
const draft = loadDraft()
if (draft?._lastStep) {
setActiveStep(draft._lastStep)
}
Versioning drafts
If your form schema changes between user sessions, a draft from schema v1 might not work with schema v2. Track the schema version:
const saveDraft = (data: Record<string, unknown>) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
schemaVersion: 3,
savedAt: Date.now(),
data,
}))
}
const loadDraft = () => {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return undefined
const draft = JSON.parse(raw)
if (draft.schemaVersion !== 3) {
localStorage.removeItem(STORAGE_KEY) // discard incompatible draft
return undefined
}
return draft.data
}
Clearing drafts on submit
Always clear the draft after successful submission:
const handleSubmit = async () => {
const data = viewerRef.current?.formData.data
const result = await postToServer(data)
if (result.ok) {
clearDraft()
}
}
Last modified on April 16, 2026