The exact problem: a reader gets three steps into a wizard, the tab reloads — a crash, a stray refresh, a phone reclaiming memory — and the form comes back empty, or worse, comes back on step four with step two’s answers gone.
Context and Prerequisites
This page assumes the wizard is already modelled as a machine rather than as paginated markup, as described in multi-step form state machines. The machine’s most useful property here is that its state is derivable: step status is a function of the answers, so persistence only has to save the answers. If your wizard stores step status directly, save the answers anyway and recompute — persisted derived state is the source of almost every “resumed on the wrong step” report.
Core Pattern: Persist Answers, Recompute Status
import type { WizardState, StepValues } from './types';
/** Bump when the shape of what we persist changes incompatibly. */
const DRAFT_VERSION = 2;
interface PersistedDraft {
readonly version: number;
readonly formId: string; // which form, so two wizards cannot collide
readonly recordId: string | null; // which record — editing order 12 is not order 13
readonly savedAt: number; // for expiry and for conflict messages
readonly values: Record<string, StepValues>;
readonly currentId: string; // a hint, re-validated on load — never trusted
}
const keyFor = (formId: string, recordId: string | null) =>
`draft:${formId}:${recordId ?? 'new'}`;
/**
* Save. Deliberately stores ONLY the answers plus a navigation hint: step status,
* validity and the computed path are all recomputed on load, so a change to the
* path function cannot resurrect a draft that no longer makes sense.
*/
export function saveDraft(state: WizardState, formId: string, recordId: string | null): void {
const draft: PersistedDraft = {
version: DRAFT_VERSION,
formId,
recordId,
savedAt: Date.now(),
values: state.values,
currentId: state.currentId,
};
try {
localStorage.setItem(keyFor(formId, recordId), JSON.stringify(draft));
} catch {
// Quota exceeded, or storage disabled in this context. A wizard that cannot
// save a draft must still work — never let this throw into the reducer.
}
}
/**
* Load. Returns null for anything we cannot safely resume: wrong form, wrong
* record, old shape, or expired. Each of those is a normal outcome, not an error.
*/
export function loadDraft(
formId: string,
recordId: string | null,
maxAgeMs = 7 * 24 * 60 * 60 * 1000,
): PersistedDraft | null {
let raw: string | null = null;
try {
raw = localStorage.getItem(keyFor(formId, recordId));
} catch {
return null;
}
if (!raw) return null;
let draft: PersistedDraft;
try {
draft = JSON.parse(raw) as PersistedDraft;
} catch {
// Corrupt entry — a partial write, or something else using the same key.
try { localStorage.removeItem(keyFor(formId, recordId)); } catch { /* ignore */ }
return null;
}
if (draft.version !== DRAFT_VERSION) return null; // no silent migration
if (draft.formId !== formId) return null;
if (draft.recordId !== recordId) return null;
if (Date.now() - draft.savedAt > maxAgeMs) return null;
return draft;
}
/**
* Rebuild the machine from saved answers. Status is recomputed by replaying the
* validators, so the reader lands on the first step that is genuinely incomplete
* rather than wherever they happened to be when the tab died.
*/
export function resume(
draft: PersistedDraft,
deps: { path: (v: Record<string, StepValues>) => readonly string[];
validate: (id: string, v: StepValues) => boolean },
): WizardState {
const path = deps.path(draft.values);
const status: Record<string, StepStatus> = {};
let firstIncomplete: string | null = null;
for (const id of path) {
const values = draft.values[id];
// A step with no saved answers, or answers that no longer validate, is not
// complete — regardless of what the reader had reached before the reload.
const complete = values !== undefined && deps.validate(id, values);
if (complete) {
status[id] = { phase: 'complete', values: Object.freeze({ ...values }) };
} else {
status[id] = firstIncomplete === null ? { phase: 'available' } : { phase: 'locked' };
if (firstIncomplete === null) firstIncomplete = id;
}
}
// Honour the saved position only if it is a step the reader may actually be on.
const hintUsable = status[draft.currentId] &&
status[draft.currentId].phase !== 'locked';
return {
stepIds: path,
currentId: hintUsable ? draft.currentId : (firstIncomplete ?? path[path.length - 1]),
status,
values: draft.values,
submitAttempted: false,
};
}
Step-by-Step Walkthrough
-
Key by form and record.
draft:checkout:newanddraft:checkout:order-9182are different drafts. Sharing one key is how editing a second record shows the first record’s answers. -
Version the payload. When the persisted shape changes, bump
DRAFT_VERSIONand let old drafts be discarded. Writing a migration for a seven-day-old draft is work that will be wrong more often than it is right. -
Save the answers only. Status, validity, progress and the path are all derived. Persisting them means persisting a snapshot of rules that may since have changed.
-
Save on a debounce, not on every keystroke.
localStoragewrites are synchronous and block the main thread; at one write per keystroke on a large draft that is measurable. A 500 ms debounce plus a flush on step change and onvisibilitychangecatches everything that matters. -
Recompute on load, then place the reader. Replay validators to rebuild status, then use the saved step id only if the recomputed status says that step is reachable.
-
Clear on success. A draft that survives its own submission is how a reader who starts a second order sees the first one’s answers.
Failure Modes and Edge Cases
1. Storage is unavailable or full
Private browsing modes, storage partitioning, and a quota already consumed by something else all make setItem throw. A wizard whose save path can throw is a wizard that breaks on the reader’s next keystroke. Wrap every access, treat failure as “no draft”, and consider surfacing it once — “we could not save your progress in this browser” is honest and lets the reader decide to finish in one sitting.
2. The draft outlives the rules
A field that was optional when the draft was written may be required today. Replaying the validators on load turns that into a corrected resume — the step comes back available rather than complete, and the reader is asked once. Trusting a persisted complete flag instead means submitting values the current rules would reject.
3. Two tabs, one key
Both tabs read the same key, both write it, and the last write wins silently. Listen for storage events to detect the other tab, and either take the newer draft or tell the reader — see resolving conflicts when restoring a draft for the reconciliation itself.
4. Sensitive values in the draft
localStorage is readable by any script on the origin and survives until deleted. Card numbers, passwords, one-time codes and government identifiers must be excluded from the persisted payload — allow-list the fields you save rather than blocking the ones you do not, so a new field is excluded by default.
// Allow-list, not deny-list: a field added next month is not persisted until
// somebody deliberately adds it here.
const PERSISTABLE: Readonly<Record<string, readonly string[]>> = {
contact: ['email', 'phone'],
delivery: ['line1', 'line2', 'city', 'postcode', 'method'],
payment: [], // nothing from this step is ever written
};
5. The key collides with another feature
Two features writing draft on the same origin is not hypothetical — analytics libraries, feature-flag clients and design-system playgrounds all write to localStorage. Namespacing the key by form and record makes a collision unlikely; validating the parsed payload’s formId makes it harmless.
6. Restoring silently
A form that quietly fills itself in is disorienting, and for a reader using a screen reader it is invisible. Announce the restore in a live region and offer a way out: “We restored your answers from 12 minutes ago. Start again.” That single sentence turns a surprising behaviour into a reassuring one.
Verification Checklist
Related
- Multi-Step Form State Machines — the machine whose answers this persists
- Autosaving Form Drafts to localStorage — the same mechanics for a single-page form
- Resolving Conflicts When Restoring a Draft — when the saved draft and the server disagree
← Multi-Step Form State Machines
Frequently Asked Questions
Should a draft go in localStorage, sessionStorage or IndexedDB?
localStorage for anything that should survive closing the tab, which is the usual expectation for a long form. sessionStorage if the draft should die with the tab — appropriate when the answers are sensitive enough that leaving them on the device is the bigger risk. IndexedDB when the draft includes files or is large enough that synchronous writes become noticeable, since it is asynchronous and has a far larger quota. For a typical wizard of text answers, localStorage with a debounced write is the right default.
How long should a draft live?
Long enough to cover the interruption you are actually protecting against, which is usually hours rather than weeks. Seven days is a reasonable outer bound for a checkout or an application form; beyond that the answers are stale, prices and availability have moved, and offering them back does the reader no favours. Store the timestamp and check it on load rather than relying on the browser to clean up, because it will not.
Should the current step be part of the persisted draft?
Save it as a hint, never as state. On load, recompute status from the answers first, then honour the saved step only if the recomputed status says that step is reachable. That way a draft written before a rule changed cannot drop the reader onto a step whose prerequisites are no longer met, and the worst case is landing one step earlier than they left off.
What should happen if the saved draft fails to parse?
Delete it and start clean. A corrupt entry means a partial write or a key collision, and there is nothing useful to recover — attempting a partial parse risks restoring half a form, which is more confusing than an empty one. Log it if you have client-side error reporting, because a pattern of corrupt drafts usually means two features are writing the same key.