1 slot open this monthBy the makers of shadcnregistryWe built shadcnregistry. Now we build your product.Book a callCheck availability
1 slot open this monthAI features?We build AI features into your product.Book a callCheck availability
1 slot open this month🚨 Slop police🚨 Slop police: we clean up vibe-coded apps.Report itReport your app
shadcnregistry
shadcnregistry
RegistryHealthInspect
Terms of ServicePrivacy Policy
  1. Registries
  2. pulld
  3. Draft Restore

Draft Restore

ui

The "you have an unsent draft — restore it?" notice that belongs above a form, and the localStorage bookkeeping behind it: it keeps what is being typed, offers it back on the next visit, and — the part that is actually hard — throws it away at the right moment. Reach for it wherever work exists only in the browser until somebody presses send: a comment, reply or review box, a contact or support form, a bug report, a job application, an article, post or newsletter draft, a profile or settings page, an invoice or expense claim, a multi-step checkout or onboarding form, a CMS entry, a message composer, a feedback or survey form, and any textarea somebody can close by accident. Common asks it answers: "save form data to localStorage react", "restore unsaved form react", "draft autosave react", "recover draft after refresh", "persist form state on reload", "restore form after accidental close", "react-hook-form persist localStorage", "formik autosave localStorage", "save textarea to localStorage", "you have an unsaved draft", "restore draft prompt", "draft recovery react", "useLocalStorage form draft", "autosave draft before submit", "clear draft after submit", "stale draft keeps coming back", "draft reappears after submitting", "text disappears on refresh", "localStorage is not defined next.js", "next.js localStorage hydration mismatch", "hydration failed because the initial UI does not match", "debounce localStorage writes", "save draft when tab closes", "beforeunload does not fire on mobile", "visibilitychange save", "two tabs same form localStorage", "下書き 自動保存 react", "リロードで入力が消える", "下書きを復元しますか", "localStorage 下書き 保存", "送信後に下書きが残る", "ハイドレーションエラー localStorage", "タブを閉じても入力を残したい", "入力途中の復元". Official shadcn/ui has nothing here, and not in the way a missing component is usually missing — it ships no persistence of any kind. Measured across the whole registry today (65 items listed, 62 fetchable, code-block, questionnaire and trace indexed and 404 on both style tracks, 223,287 bytes of files[].content concatenated): localStorage, sessionStorage, draft, restore, recover, autosave, beforeunload, discard and expire are every one of them a zero hit. Its form, field and textarea hold a value while the page is open and let go of it when the page does. The reason to install this rather than write the two obvious lines — setItem in an onChange, getItem in a useState initialiser — is that those two lines are wrong in four separate places, and three of them only surface after you ship. The first is when to throw the draft away, and it is the one that turns a helpful feature into a haunting. A draft that outlives a successful submit comes back as a restore prompt for work that was sent days ago, and the person reading it cannot tell that prompt from a real one. Clearing on submit is the obvious answer and it is not sufficient by itself, because the clear sits on the one path everybody forgets: the happy one. So most of it is derived here instead. A stored draft that matches what the form is currently showing is deleted rather than offered, on the grounds that a draft identical to reality is not a draft — which for an edit form is the entire problem solved, because you edit an article, save it, come back, and the server hands over the very text the draft holds, so it goes quietly without anybody having remembered anything. For a create form it is still clear() that does it, and that limit is stated rather than hidden: a comment box that resets to empty after sending looks exactly like a comment box somebody emptied by hand, and nothing in here can tell those two apart. maxAge bounds what a forgotten clear() can cost — a draft past its age is deleted on sight rather than offered, seven days by default — and version does the same for a form whose shape has changed, so an old draft is dropped instead of restored into fields that have since moved. The clear also cancels a save already on its way, which is the same bug in miniature: a submit that lands inside the debounce window would otherwise have the timer write the draft back a few hundred milliseconds after it was cleared. The second is hydration. getItem in a useState initialiser runs on the server too, where there is no localStorage, and the client's first render then disagrees with the HTML it is hydrating. Nothing is read during render here: pending is null until an effect has run, so the server and the first client render agree and the notice arrives a frame later rather than flashing for every visitor who has no draft at all. The third is the key, which is a privacy question wearing a naming question's clothes. A key of just "draft" is shared by every form on the origin; a key per form but not per account hands the next person to sign in on a shared machine the last one's unsent text. So the id is required and scope is there for the user id, and a draft stored under one scope is neither offered to nor deleted by another. The fourth is the write itself. A setItem per keystroke is a synchronous main-thread write per keystroke, so writes are debounced — and a debounce with nothing flushing it loses whatever was typed inside the last window, which is exactly the moment somebody closes the tab. Both pagehide and visibilitychange flush immediately, because a phone backgrounds a tab rather than unloading it and may never fire pagehide at all; beforeunload, which is what gets reached for first, is the one that does not help here. A fifth was not foreseen and came out of testing it, so it is written down rather than smoothed over: the debounce has to be keyed on the draft's serialised content, not on the value prop. value={{ title, body }} is a new object on every render and a controlled form re-renders on every keystroke, so keyed on identity the timer is restarted by the very typing it is waiting for, and a whole-form draft is never written once. Keyed on content, a re-render that changed nothing leaves the pending save alone. What you get: useDraft() for the persistence, returning pending, restore(), clear(), flush() and the storageKey, and DraftRestore for the notice, which takes the hook's result and renders nothing visible when there is no draft, so it can be left mounted above the form rather than conditionally rendered. It takes a notice rather than a dialog on purpose: arriving at a page with an old draft on it is not an emergency, and a modal would make somebody answer a question about work they may not even remember before they can read their own form. The live region is mounted whether or not there is a draft and only its text comes and goes, because a region that mounts with its message already inside it is registered too late to announce that message — and this notice always appears after mount, so conditional rendering would mean it is never announced at all. The timestamp and the buttons sit outside that region: inside it, the ticking relative time would have the page announce "saved 3 minutes ago" every minute for as long as the tab is open. It composes time-ago for that timestamp rather than freezing a string that goes stale while somebody reads it, every label is overridable for i18n, and all of it is shadcn tokens so it follows light and dark. Honest limits: two tabs on one form share a key and the last write wins, which is not reconciled here — what is handled is the case that misleads, the other tab submitting, since a storage event removing the key takes this tab's offer down rather than leaving it asking about work already sent. localStorage holds strings, so the value must survive JSON — a File, a Map or a Date comes back as something else, and a value JSON cannot carry at all is skipped rather than throwing. It is also not encrypted and not a backup: a draft lives in one browser profile on one device, and a private window throws it away on close. Anything that must not be lost belongs on a server, with this as the thing that covers the gap between keystrokes and saves. Distinct from its neighbours here: unsaved-changes-guard interrupts the departure and stores nothing, so it protects work on the way out while this one is what makes it survive having left — the two are complementary and good together on the same form; save-status reports the fate of a background write to a server and owns no copy of the data; autosize-textarea and char-counter are about the field rather than its contents outliving the page; announcement-bar also persists to localStorage but only remembers a dismissal, which is a preference rather than somebody's writing; and idle-timeout coordinates tabs over a session rather than over a draft.

pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/draft-restore.json"
Premium blocks for shadcn/uiShadcnblocks.com · AffiliateExplore
  • lucide-react
  • time-ago
components/ui/draft-restore.tsx