Auto Save
Headless draft autosave for any value, with status chrome you can place in a header or footer.
Auto Save is the draft-persist engine without Formily. useAutoSave owns debounce, the in-flight queue, mid-flight dirty, and revert-to-baseline. AutoSaveStatus is a quiet live region. AutoSaveProvider + useAutoSaveStatus let a page header read status when it is not a sibling of the engine. Formily pages keep using Form Page — that recipe is an adapter over this engine.
When To Use
- The page holds React state (or any mutable store), not a Formily
Form. - Status belongs in the header / tab strip, not only a form footer.
- You need the same queue as Form Page (manual save waits for in-flight auto-save; edits during a request stay dirty).
- Prefer Form Page when the page is a Formily form —
persistencestays the public API.
Features
| Area | Behavior |
|---|---|
| Engine | Snapshot dirty detection, 3s debounce default, promise queue, mid-flight dirty |
| Gate | gate defers auto-save silently; manual save() throws DraftPersistBlockedError |
| Chrome | AutoSaveStatus — Unsaved / Saving / Draft saved {time} / Retry |
| Distance | AutoSaveProvider + useAutoSaveStatus (TanStack Query / RHF class) |
| Formily | Form Page useFormPersistence wraps this hook |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/auto-save.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/auto-save.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/auto-save.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/auto-save.jsonWith a namespace: npx shadcn@latest add @f-ui/auto-save.
registryDependencies: utils, button, fui-i18n. Runtime: lucide-react.
Usage
import { AutoSaveProvider } from "@/components/f-ui/auto-save/auto-save";
import { AutoSaveStatus } from "@/components/f-ui/auto-save/auto-save-parts/auto-save-status";
import { useAutoSave } from "@/components/f-ui/auto-save/use-auto-save";
function Editor() {
const [draft, setDraft] = useState(initial);
useAutoSave({
value: draft,
persist: (next) => api.saveDraft(next),
autoSave: { debounceMs: 500 },
enabled: headerStatus === "draft",
});
return /* fields that call setDraft */;
}
return (
<AutoSaveProvider>
<header><AutoSaveStatus /></header>
<Editor />
</AutoSaveProvider>
);Lift useAutoSave to the page and pass value={autosave} when the hook already lives in the common ancestor.
Mutable stores (no new React reference) pass getValue and call notifyChanged() after writes.
persist owns payload construction and post-write merge. After replacing local state with a server document, call commitBaseline(next) so the page does not stay Unsaved.
Examples
Status In The Header
The engine lives in the editor. The live region sits on the title row — same provider, not a sibling of the hook. Type in the title; wait for Saving then Draft saved.
Note
"use client";
import { useState } from "react";
import { AutoSaveProvider } from "@/components/f-ui/auto-save/auto-save";
import { AutoSaveStatus } from "@/components/f-ui/auto-save/auto-save-parts/auto-save-status";
import { useAutoSave } from "@/components/f-ui/auto-save/use-auto-save";
import { Input } from "@/components/ui/input";
type Note = { title: string };
async function fakePersist(_value: Note) {
await new Promise((r) => setTimeout(r, 200));
}
function NoteEditor() {
const [note, setNote] = useState<Note>({ title: "Q3 plan" });
useAutoSave({
value: note,
persist: fakePersist,
autoSave: { debounceMs: 600 },
});
return (
<Input
aria-label="Title"
value={note.title}
onChange={(e) => setNote({ title: e.target.value })}
/>
);
}
export function AutoSaveHeaderDemo() {
return (
<AutoSaveProvider>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-sm font-medium">Note</p>
<AutoSaveStatus />
</div>
<NoteEditor />
</div>
</AutoSaveProvider>
);
}API Reference
useAutoSave
| Option | Type | Default | Description |
|---|---|---|---|
value | T | — | Change detector for immutable React state. |
getValue | () => T | — | Required when value is omitted. |
persist | (value: T) => Promise<unknown> | — | Host write. Kit does not diff or refetch. |
serialize | (value: T) => string | JSON.stringify | Dirty snapshot. |
commitValue | (sent, result) => T | sent | Map baseline after success (e.g. new id). |
gate | (value: T) => boolean | Promise<boolean> | — | Auto defers; manual save throws. |
enabled | boolean | true | No-op when false. |
autoSave | boolean | { enabled?, debounceMs? } | off | Debounced persist. |
debounceMs | number | 3000 | Used when auto-save is on. |
Returns status, lastSavedAt, isDirty / hasUnsavedChanges, isSaving, isActive, save, retry, commitBaseline, notifyChanged.
AutoSaveProvider
Wrap the page (or the subtree that includes both the engine and the indicator). Omit value when a descendant useAutoSave publishes. Pass value when the hook already lives in this component.
AutoSaveStatus Props
Omit status props to read the provider. Passing status / lastSavedAt / onRetry still wins.
| Prop | Type | Default | Description |
|---|---|---|---|
status | "idle" | "dirty" | "saving" | "saved" | "error" | from context | |
lastSavedAt | Date | null | from context | |
onRetry | () => void | from context | Shown on error. |