npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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-kit

react 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

  1. Changes are saved after one second of inactivity.
  2. A draft saved during the current visit does not show a restore dialog.
  3. After refresh, tab close, or a later visit, a valid draft sets hasDraft to true.
  4. restore() calls your onRestore; discard() deletes the stored draft.
  5. 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 prepublishOnly

Publish manually with npm login then npm publish.

License

MIT