Skip to main content
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.

Client-side: IndexedDB via FormStorage

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>
)}

Multi-step form persistence

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