Form persistence
Import createPersistenceMiddleware from form-please/persistence when a form
needs a durable editable draft. React Hook Form still owns the live values and
form state. Persistence stores only the complete schema input.
Configure and restore
Create one feature, add that exact reference to middleware, and pass the form
and feature to usePersistence. The hook subscribes to persistence state and
starts restore() after mount. It loads at most once after a successful or
empty result.
const settingsPersistence = createPersistenceMiddleware({
adapter: createLocalStorageAdapter(() => localStorage),
key: "settings-draft",
onError: (error, { operation }) => {
console.error(`Settings persistence ${operation} failed`, error)
},
version: 1,
})
export function SettingsDraftForm() {
const form = nativeFormKit.useForm(settingsDefinition, {
defaultValues: { theme: "system" },
middleware: [settingsPersistence],
})
usePersistence(form, settingsPersistence)
return <nativeFormKit.AutoForm form={form} />
}The lazy storage getter keeps localStorage access out of module evaluation.
The hook starts restore only after the client mounts.
restore() returns one of these results:
"applied": the stored input became the live input;"empty": no stored draft exists;"cancelled": middleware did not commit the restore;"transformed": middleware changed the restored input;"conflict": live input changed while the adapter was loading.
An applied restore uses the managed update pipeline with a
{ type: "persistence", action: "restore" } source. It preserves the original
defaultValues, recalculates dirty state, and clears errors, touched state, and
submission metadata. It does not run validation. A draft may contain temporary
invalid input.
Resolve a restore conflict
Persistence never overwrites a local edit made while load() is pending. The
handle enters the conflict phase and returns "conflict". Call start() to
keep the current form and save it. Remount the form if the user must discard the
local input and try the load again.
Use the lower-level feature.handle(form) and root useSnapshot helper when the
application must call start() without an initial load. A normal start does not
write the unchanged initial values. A start after a conflict or restore failure
immediately saves the current input.
Observe and control saves
After restore or start, persistence observes all RHF value publications. This
includes generated controls, form.update, and direct form.api changes.
Autosave uses a trailing 500 ms delay by default. Set saveDelay to another
finite non-negative number.
The hook result includes a reactive snapshot and these handle operations:
flush()cancels the delay and waits for the latest active input to save;clear()removes stored data without changing live input;getSnapshot()returns the restore phase and save status;subscribe()implements the external-store contract foruseSnapshot.
The hook handles the restore Promise rejection because the same error remains
available through the failed snapshot and the feature's onError callback.
Call the returned restore() operation to retry explicitly after a failure.
Writes are sequential and coalesced. If input changes during a save, the latest input is saved afterward. Clearing an active draft suppresses an immediate rewrite. The next edit creates the draft again.
Storage errors never roll back form values. A failed save reports
save.status === "failed" with operation: "save" or "clear". The next edit
or flush() retries. A restore failure uses the top-level failed phase and can
be retried with restore().
The optional onError callback receives the error and a
PersistenceErrorDetails object. Import that type from
form-please/persistence when the observer is declared separately; its
operation is "restore", "save", or "clear".
Own the adapter
An adapter is a keyed asynchronous transport:
type FormPersistenceAdapter = {
load(key: string): Promise<JsonValue | undefined>
save(key: string, value: JsonValue): Promise<void>
remove(key: string): Promise<void>
}Form Please owns the JSON-safe envelope. The application owns storage, authentication, authorization, request cancellation, and retention.
Query string with nuqs
Use replace history and shallow URL updates so autosave does not create a
browser history entry or navigation for each edit.
import type { , } from "form-please/persistence"
export type = <{
(): string | null
(: string | null): unknown | <unknown>
}>
/** Adapts a nuqs string state to the Form Please persistence transport. */
export function (
: ,
): {
return {
async () {
const = .()
if ( === null) return
return .() as
},
async () {
await .(null)
},
async (, ) {
await .(.())
},
}
}The live Query string persistence example shows the
adapter with useQueryState and NuqsAdapter.
TanStack Query
TanStack Query can coordinate a remote draft request and its client cache. The request functions remain application-owned.
export function createTanStackQueryPersistenceAdapter(
queryClient: QueryClient,
requests: DraftRequests,
): FormPersistenceAdapter {
return {
load(key) {
return queryClient.fetchQuery({
queryFn: () => requests.load(key),
queryKey: ["form-draft", key],
})
},
async remove(key) {
await requests.remove(key)
queryClient.setQueryData(["form-draft", key], undefined)
},
async save(key, value) {
await requests.save(key, value)
queryClient.setQueryData(["form-draft", key], value)
},
}
}Version and encode drafts
Each stored envelope contains the persistence protocol version, the application
version, and the encoded payload. Increase version when the editable input
shape changes. Supply migrate(value, fromVersion, toVersion) to convert the
decoded old value. A successful migration is immediately rewritten in the
current envelope.
Plain values support null, booleans, strings, finite numbers, undefined,
arrays, and plain objects. Unsupported values fail with their input path. Add
createDateCodec() for Date. Add another explicit PersistenceCodec for any
other opaque value that must cross the storage boundary.
Do not treat a persisted draft as trusted input. Standard Schema still validates on submit, and a server must validate every submitted value again.
Boundaries
One form accepts one persistence feature. The feature does not synchronize multiple tabs or forms, save on page unload, retain RHF metadata, or persist managed history. The adapter can add cross-client coordination when the product requires it.