react-form-draft-kit
v0.1.0
Published
Draft persistence, recovery, and unsaved-change guards for React forms.
Readme
react-form-draft-kit
Keep unfinished React forms safe. react-form-draft-kit saves a form draft in browser localStorage, lets users restore it after a refresh or return visit, and provides unsaved-change helpers.
It is headless: your app owns the inputs, layout, text, colors, and design system. This package only manages draft persistence and includes two small unstyled helpers.
Features
- Auto-save (one-second debounce by default) and manual
save/flush - Restore only on a later page load—never interrupt a user while filling the current form
- Expiry (seven days by default), schema versioning, and migrations
- Sensitive-field exclusion and JSON serialization hooks
- Safe storage-error handling, React Hook Form adapter, and Next.js support
- ESM, CommonJS, and TypeScript declarations for React 18+
Installation
npm install react-form-draft-kitreact and react-dom are peer dependencies. Install react-hook-form only if you use its adapter.
Normal React form
Create fields and styling exactly as you normally would. Pass current values to useDraft; apply recovered values with onRestore.
import { useState, type FormEvent } from 'react';
import { DraftRestorePrompt, DraftStatus, useDraft, useExitWarning } from 'react-form-draft-kit';
type Values = { name: string; email: string; password: string };
export function ProfileForm() {
const [values, setValues] = useState<Values>({ name: '', email: '', password: '' });
const draft = useDraft({
draftKey: 'profile:current-user', // stable and unique per form/user
values,
onRestore: setValues,
exclude: ['password']
});
useExitWarning({ isDirty: draft.isDirty, flush: draft.flush });
const update = <K extends keyof Values>(key: K, value: Values[K]) =>
setValues((current) => ({ ...current, [key]: value }));
async function submit(event: FormEvent) {
event.preventDefault();
await saveProfile(values);
draft.clear(); // Only clear after a successful request.
}
return <form onSubmit={submit}>
<label>Name <input value={values.name} onChange={(e) => update('name', e.target.value)} /></label>
<label>Email <input value={values.email} onChange={(e) => update('email', e.target.value)} /></label>
<label>Password <input type="password" value={values.password} onChange={(e) => update('password', e.target.value)} /></label>
<button type="submit">Save profile</button>
<DraftRestorePrompt open={draft.hasDraft} draft={draft.draft}
onRestore={draft.restore} onDiscard={draft.discard} />
<DraftStatus status={draft.status} error={draft.error} />
</form>;
}Lifecycle
- Changes are saved after one second of inactivity.
- A draft saved during the current visit does not show a restore dialog.
- After refresh, tab close, or a later visit, a valid draft sets
hasDrafttotrue. restore()calls youronRestore;discard()deletes the stored draft.- Call
clear()after a successful submit.
Styling and custom UI
The package has no CSS, colors, theme, or design-system dependency. The purple buttons in this repository's demo belong only to the demo.
Style the provided prompt
<DraftRestorePrompt open={draft.hasDraft} draft={draft.draft}
onRestore={draft.restore} onDiscard={draft.discard}
className="my-draft-dialog" />.my-draft-dialog { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 24px; }
.my-draft-dialog button:first-of-type { background: #16a34a; color: white; }
.my-draft-dialog button:last-of-type { background: #dc2626; color: white; }Use your own modal and buttons
Render props let you use any component library or custom UI.
<DraftRestorePrompt open={draft.hasDraft} draft={draft.draft}
onRestore={draft.restore} onDiscard={draft.discard}>
{({ draft: metadata, restore, discard }) => (
<MyModal title="Continue where you left off?">
<p>Saved at {new Date(metadata!.savedAt).toLocaleString()}</p>
<Button onClick={restore}>Continue editing</Button>
<Button variant="danger" onClick={discard}>Start over</Button>
</MyModal>
)}
</DraftRestorePrompt>DraftStatus also supports render props:
<DraftStatus status={draft.status} error={draft.error}>
{({ status, error }) => status === 'saving' ? <Spinner /> :
error ? <InlineAlert>{error.message}</InlineAlert> : null}
</DraftStatus>React Hook Form
import { useForm } from 'react-hook-form';
import { DraftRestorePrompt, useExitWarning } from 'react-form-draft-kit';
import { useReactHookFormDraft } from 'react-form-draft-kit/react-hook-form';
type Post = { title: string; body: string; internalNote: string };
export function PostForm() {
const form = useForm<Post>({ defaultValues: { title: '', body: '', internalNote: '' } });
const draft = useReactHookFormDraft({ form, draftKey: 'new-post', exclude: ['internalNote'] });
useExitWarning({ isDirty: draft.isDirty, flush: draft.flush });
const submit = form.handleSubmit(async (values) => {
await createPost(values);
draft.clear();
});
return <form onSubmit={submit}>
<input {...form.register('title')} placeholder="Title" />
<textarea {...form.register('body')} placeholder="Write your post" />
<button type="submit">Publish</button>
<DraftRestorePrompt open={draft.hasDraft} draft={draft.draft}
onRestore={draft.restore} onDiscard={draft.discard} />
</form>;
}Next.js
Hooks must run in a Client Component; the package is safe to import during SSR.
'use client';
import { useDraft } from 'react-form-draft-kit';It works with App Router and Pages Router. useExitWarning handles browser close and refresh. For internal navigation, use the router-agnostic helper in your own link/router callback:
const navigation = useNavigationGuard(draft.isDirty);
if (navigation.confirmNavigation()) router.push('/settings');Browsers control the wording of native close/refresh dialogs.
API
Required useDraft options
| Option | Description |
| --- | --- |
| draftKey | Stable unique ID, e.g. checkout:user-42. |
| values | Current form values; provide a new value reference when fields change. |
| onRestore(values) | Applies recovered values to your form state. |
Optional options
| Option | Default | Description |
| --- | --- | --- |
| enabled | true | Turns persistence on/off. |
| debounceMs | 1000 | Auto-save delay. |
| ttlMs | 604800000 | Expiry in milliseconds (7 days). |
| version | 1 | Schema version, used with migrate. |
| storageKeyPrefix | react-form-draft: | localStorage key prefix. |
| exclude | — | Dot path(s) or predicate for values not to store. |
| serialize / deserialize | — | Convert values to/from JSON-safe stored data. |
| migrate | — | Convert an earlier-version draft, or return null to discard it. |
| onSave, onRestoreSuccess, onExpire, onError | — | Lifecycle callbacks. |
Returned API
| Member | Description |
| --- | --- |
| hasDraft | A loaded draft is awaiting the user's choice. |
| draft | Metadata: savedAt, expiresAt, version. |
| status | idle, ready, saving, saved, restored, error, or unavailable. |
| error, isDirty | Latest error and whether a change is waiting to save. |
| save() / flush() | Save immediately; flush cancels the pending debounce first. |
| restore() / discard() | Apply or delete the loaded draft. |
| clear() | Delete the stored draft, normally after submit. |
| getDraftValues() | Read loaded values before restoring, or null. |
Sensitive values, dates, and files
Drafts are plain JSON in localStorage; they are not encrypted. Never store passwords, tokens, one-time codes, or payment data.
exclude: ['payment.cardNumber', 'payment.cvv', 'password']
// or
exclude: (path) => path.startsWith('payment.') || path === 'password'File, Blob, Map, Set, and class instances should be excluded or transformed. File uploads should normally be selected again by the user.
useDraft<Values, { title: string; publishAt: string }>({
draftKey: 'scheduled-post', values, onRestore: setValues,
serialize: (v) => ({ ...v, publishAt: v.publishAt.toISOString() }),
deserialize: (v) => ({ ...v, publishAt: new Date(v.publishAt) })
});Migrations
Increase version whenever stored form shape changes.
useDraft({
draftKey: 'profile:42', values, onRestore: setValues, version: 2,
migrate: (draft) => {
if (draft.version !== 1) return null;
const old = draft.values as { fullName: string; email: string };
const [firstName = '', lastName = ''] = old.fullName.split(' ');
return { firstName, lastName, email: old.email };
}
});Malformed, expired, and incompatible drafts are removed automatically. If storage is blocked or full, the form still works and onError receives the error. With the same draftKey in multiple tabs, the last save wins.
Local development and publishing
npm install
npm run demo # open http://127.0.0.1:4173/
npm run typecheck
npm test
npm run build
npm run prepublishOnlyPublish manually with npm login then npm publish.
