Persist and populate react-hook-form form using storage of your choice
- react-hook-form-persist
Install the package locally within you project folder with your package manager:
With npm:
npm install react-hook-form-persistWith yarn:
yarn add react-hook-form-persistWith pnpm:
pnpm add react-hook-form-persistThis example uses React Hook Form 7:
import React from "react";
import { useForm } from "react-hook-form";
import useFormPersist from 'react-hook-form-persist'
export default function App() {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors }
} = useForm();
useFormPersist("storageKey", {
watch,
setValue,
storage: window.localStorage, // default window.sessionStorage
exclude: ['baz']
});
const onSubmit = data => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>foo:
<input {...register("foo")} />
</label>
<label>bar (required):
<input {...register("bar", { required: true })} />
</label>
{errors.bar && <span>This field is required</span>}
<label>baz (excluded):
<input {...register("baz")} />
</label>
<input type="submit" />
</form>
);
}Persist all form fields:
useFormPersist('form', {watch, setValue});Persist all form fields except password:
useFormPersist('form', {watch, setValue, exclude: ['password']});Supply initial values to React Hook Form:
const { watch, setValue, reset } = useForm({
defaultValues: { title: '', details: '', password: '' }
});
useFormPersist('form', { watch, setValue, exclude: ['password'] });Selected saved fields replace the current form values. Missing top-level fields
and excluded fields keep their current values, normally the RHF defaults on
initial mount. For example, saved { title: 'Draft' } restores the title while
keeping the default details and password. Saved null, false, 0, and ''
are passed to RHF's setValue without substituting defaults; RHF can normalize
them for native inputs. When no entry exists, the current selected values are
saved. Excluded fields can have RHF defaults without being saved or restored.
Restoration uses setValue, so RHF retains its own default-value baseline:
reset() returns to those defaults, and dirty: true compares restored values
against them. Changing the defaultValues object on a rerender does not reset
an existing form; use RHF's reset
when that is intended. Changing the persistence key alone also does not reset
the form. These rules do not add a deep merge for nested objects or arrays.
For async RHF defaults, keep persistence disabled until loading completes so restoration happens after RHF initializes the form:
const { watch, setValue, formState: { isLoading } } = useForm({
defaultValues: async () => loadDefaults()
});
const { isSynchronized } = useFormPersist(isLoading ? null : 'form', {
watch,
setValue
});In effects that use initialized values, wait for both !isLoading and
isSynchronized. The persistence flag does not await async defaults by itself.
The null-key and readiness APIs in this example describe repository source and
require a package release containing those additions.
The async recipe requires RHF's isLoading support (7.41+); it is tested with
RHF 7.43.9 and 7.89.0 on React 18.1.0, in normal mode and StrictMode. For older
RHF versions, load the data before mounting a form with synchronous defaults.
The ordinary recipe calls React Hook Form's watch() without arguments. That
subscribes at the useForm level, so field edits can rerender the component
owning the form. Moving the same watch call into a child does not move that
subscription. See React Hook Form's watch
and useWatch documentation.
For forms with synchronous defaultValues, use a small persistence child to
isolate the value subscription. This React Hook Form 7 example uses the existing
watch configuration as a values getter; useWatch supplies child-local
updates, and getValues supplies the current form values without a root
subscription:
import React from 'react';
import { useForm, useWatch } from 'react-hook-form';
import useFormPersist from 'react-hook-form-persist';
// Declare this outside Form so parent renders do not remount persistence.
function FormPersistence({ control, getValues, setValue, persistKey }) {
useWatch({ control, disabled: persistKey === null });
useFormPersist(persistKey, { watch: getValues, setValue });
return null;
}
function Form({ persistKey = 'form' }) {
const { register, control, getValues, setValue } = useForm({
defaultValues: { title: '', details: '' }
});
return (
<form>
<input {...register('title')} />
<textarea {...register('details')} />
<FormPersistence
control={control}
getValues={getValues}
setValue={setValue}
persistKey={persistKey}
/>
</form>
);
}Call useWatch before useFormPersist, unconditionally. getValues alone
does not subscribe to edits; do not remove useWatch or call it inside the
watch callback. Reading current values also avoids using a stale useWatch
snapshot when persistence is re-enabled. Only null disables persistence;
the observer is disabled separately by the same condition.
Provide synchronous defaultValues for every field and keep
shouldUnregister: false (the default) for this recipe. In our tests, omitting
defaults can make later input registration look like an edit and recreate an
expired entry. The scalar/nested-field recipe is tested with React 18.1.0 and
React Hook Form 7.31.1, 7.43.9 and 7.89.0, including development StrictMode.
It is not a guarantee for every version in the package's peer range.
The persistence child still rerenders on value updates and saves eligible
snapshots. This does not debounce writes or promise a particular speedup.
Other subscriptions, validation and application state can still rerender the
parent. include/exclude select stored fields, not observer subscriptions.
Keep any supplied storage, restoration callbacks and codecs stable as described
below. Options and the returned clear() retain their normal behavior; use
clear from the persistence child rather than adding a second persistence hook.
Check subscription placement separately for dynamic rows. With React Hook Form
7.31.1 and 7.43.9, a useFieldArray in the parent can miss restoration performed
by this child's earlier effect, leaving rendered rows stale even when form
values and storage are correct. Calling useFieldArray first in the parent
does not fix that cross-component effect order. See Restoring arrays
for the same-component recipe, and verify rendered rows as well as values
before applying this child pattern to field arrays.
Call the hook unconditionally and pass null as the key to disable persistence:
const { watch, setValue } = useForm();
const { clear } = useFormPersist(persistKey ?? null, { watch, setValue });Only null disables the hook. All string keys, including '', remain valid.
The hook always returns { clear, isSynchronized }; clear() does nothing and
isSynchronized is false while disabled.
While disabled, the hook does not call watch, access storage, invoke codecs,
restore values, or run onDataRestored/onTimeout/onStorageError. Existing stored data and
current form values are left intact, and edits are not persisted.
Changing from null to a string reads that key's latest stored data using the
current configuration, just like changing an enabled key. This also applies
when re-enabling the same key: stored values can replace edits made while
disabled. If no entry exists, the current selected values are saved. Expiration
is checked when re-enabled; disabling does not pause the entry's age. Expired
data is removed without immediately replacing it with unchanged form values.
An empty include list only selects no fields; it still reads storage and
handles expiration. Use a null key when persistence should be fully disabled.
Enabling persistence retains the normal codec and storage error behavior below.
An explicitly supplied storage: window.localStorage expression is evaluated
by your component before the hook is called, even when its key is null.
Use isSynchronized to wait for the initial storage check before using form
values in an effect. This addition describes unreleased repository source and
will be available after a package release.
const { watch, setValue } = useForm({ defaultValues: { query: '' } });
const { isSynchronized } = useFormPersist(persistKey, { watch, setValue });
const query = watch('query');
React.useEffect(() => {
if (!isSynchronized) return;
// Use query here, after any selected saved fields have been restored.
}, [isSynchronized, query]);The flag starts false and becomes true on a render after the current
activation's initial storage check succeeds. An activation is one continuous
enabled period with the same key and supplied storage reference:
- An unexpired entry is ready after selected fields are set and the synchronous
onDataRestoredcallback returns. Empty storage and entries with no selected fields also become ready. Unselected or missing fields keep their current values. - An expired entry becomes ready after
onTimeoutreturns and removal succeeds. The existing timeout behavior still prevents unchanged defaults from immediately recreating the entry. - Changing the key or
storagereference makes the flagfalseimmediately, before the new target is checked. Passingnullkeeps itfalse; re-enabling starts a new check, even for the same key. A real remount also startsfalse. - Failed initial storage access, reads, or expiry removal do not become ready.
With
onStorageError, the activation remains suspended andfalseuntil a new activation begins. Use that callback to show a fallback or error state rather than displaying a loading indicator indefinitely. Without it, the error propagates. Deserialization,setValue, and restoration callback errors also propagate without completing readiness.
This is an initial-restoration flag, not a saving, validation, or storage-health
status. Later writes and clear() do not reset it, even if they fail. Changes to
callbacks or codecs do not start a new activation; keep them stable because the
existing restoration effect can run again when their references change. Async
default values, validation, and promises returned by callbacks are not awaited.
Readiness is set only during the synchronous restoration effect; there is no background completion after unmount. Development StrictMode may replay effects, and application effects can run again on remounts or dependency changes. This flag does not guarantee exactly one API request; give requests appropriate cleanup, cancellation, or deduplication for your application.
Persist only selected fields with the optional include allowlist:
useFormPersist('form', {watch, setValue, include: ['email']});- Omit
includeto persist all fields except those inexclude. - Both options match exact top-level keys in the form values. Including
profilepersists its whole nested object;profile.emaildoes not select a nested path. excludetakes precedence when a field appears in both lists.include: []selects no fields. No fields are restored or written, andonDataRestoredreceives{}when an unexpired stored entry is read.- Selection applies after deserialization, before
setValueandonDataRestored, and before serialization._timestampremains reserved expiration metadata; including or excluding it does not make it a form field or disable expiration.
The field-path segments __proto__, constructor, and prototype are reserved,
including in dotted or bracketed names. A field with a reserved name, or with a
reserved path in any nested enumerable field, is omitted before restoration,
onDataRestored, and serialization. If a nested field is reserved, its entire
top-level field is omitted; the original value is not partially rewritten or
mutated. An include entry cannot override this check. Custom codecs should
still return trusted, validated form values; this check does not validate their
schema.
Changing either list alone saves the currently selected form values; it does not
reload old values from storage or reset fields in the form. Equal-content inline
arrays are safe. Remounting, or changing the storage key, storage, deserializer,
onDataRestored, or setValue reference, reads stored data using the current
selection. Keep callbacks and any setValue wrapper stable (for example with
useCallback); changing a restoration dependency while widening the selection
can restore older stored values over current edits.
When no current fields are selected, the hook skips writing and leaves any stored
entry intact. Field selection is not a storage cleanup mechanism: old data may
remain, and a later mount with a wider selection can restore it. Call the returned
clear() to remove an entry explicitly. Expired entries are still removed even
when include is empty. A nonempty write replaces the entry with the selected
values, as with exclude alone.
JSON-compatible arrays, including arrays of objects used by controlled
multi-selects, can be persisted with the default codecs. Connect a controlled
component to React Hook Form's Controller or useController so its displayed
selection follows the restored form value.
For dynamic rows, initialize useFieldArray before useFormPersist:
const { control, watch, setValue } = useForm({
defaultValues: { filters: [] }
});
const { fields, append, remove } = useFieldArray({ control, name: 'filters' });
useFormPersist('filters', { watch, setValue });The persistence hook restores values through setValue; it does not manage
field-array subscriptions or registration. With React Hook Form 7.31.1 and
7.43.9, calling persistence first can restore form values and storage while
leaving the rendered rows empty. Initializing useFieldArray first avoids
that ordering problem in those versions. React Hook Form 7.89.0 supports
both orders in our remount tests.
Keep the default shouldUnregister: false for this pattern. In development
StrictMode, our React Hook Form 7.89.0 tests with shouldUnregister: true
leave restored dynamic rows out of sync, even though form values and storage
retain the array. The same case occurs with a plain useEffect calling
setValue, without this persistence hook. If you need unregister-on-unmount,
verify the rendered rows as well as form values with your React Hook Form
version and registration setup.
Storage contains strings. By default, the hook uses JSON.stringify to save
values and JSON.parse to restore them. Pass serialize and deserialize to
support values such as Date, BigInt, or objects from a date library.
For a form whose birthday field is a Date and visits field is a BigInt:
// Define codecs outside the component to keep their references stable.
const serialize = (data) => JSON.stringify({
...data,
birthday: data.birthday.toISOString(),
visits: data.visits.toString()
});
const deserialize = (serialized) => {
const data = JSON.parse(serialized);
return {
...data,
birthday: new Date(data.birthday),
visits: BigInt(data.visits)
};
};
useFormPersist('form', { watch, setValue, serialize, deserialize });Both callbacks are optional and synchronous. serialize receives a record of
form values after include and exclude have been applied and must return a string.
deserialize receives the stored string and must return a record of restored
values. Field selection also applies to the returned record, so previously stored
unselected or excluded fields are not restored. Only provide a callback if you need to change
its corresponding JSON default.
When timeout is configured, the hook adds a reserved _timestamp property to
the record before serialization. Preserve that property in both codecs, as the
example does with ...data, so expiration still works. _timestamp is removed
before calling setValue or onDataRestored; do not use it as a form field.
Expired entries are removed without replacing them with unchanged defaults on mount or subsequent renders. Changes to selected form values resume saving, as do changes to the storage key, storage, serializer, timeout, or field selection.
Keep codec references stable, for example by defining them outside the component
or using useCallback. Changing deserialize causes stored data to be restored
again; changing serialize saves current values with the new serializer. A
deserializer that creates new objects on every render can otherwise cause
repeated restoration.
By default, codec errors, malformed JSON, and storage errors propagate from the hook's effects to React. The hook does not silently discard invalid data or fall back to defaults. Use an error boundary if you need to handle these errors, and validate stored data in your deserializer when your format requires it.
File and FileList values do not round-trip through the default JSON codecs.
Keep uploads in application-managed state and leave file fields out of
persistence. For example, exclude the exact top-level file field:
const { register, watch, setValue } = useForm();
useFormPersist('form', { watch, setValue, exclude: ['attachment'] });
// Register the native input without binding a saved value to it.
<input type="file" {...register('attachment')} />Alternatively, allowlist only the fields that should be saved:
useFormPersist('form', { watch, setValue, include: ['title', 'description'] });Selection applies on both save and restore, so these configurations also
ignore an attachment value left by an older stored entry. Selection matches
top-level keys: for profile.attachment, exclude profile or use an
application-specific persisted shape; exclude: ['profile.attachment'] does
not exclude that nested path. See Selecting fields for
storage cleanup behavior.
A browser file input cannot have a filename or saved object assigned to its
value; it accepts only the empty string programmatically. Such assignments
can throw InvalidStateError. Custom codecs cannot restore a browser file
selection or bypass this restriction. Excluding the field does not fix a
component that independently binds a nonempty value to its file input.
After a real remount, the user must select the file again unless your app
separately retains and manages the upload. This hook does not automatically store file bytes
or repopulate native file inputs.
Browsers can deny access to storage, including the window.sessionStorage
property itself, for example in restricted cross-origin frames. Storage writes
can also fail when a quota is reached. To keep the form usable when persistence
fails, opt in with onStorageError:
useFormPersist('form', {
watch,
setValue,
onStorageError: (error) => {
console.warn('Form changes are no longer being saved', error);
}
});Without this callback, storage errors still throw. With it, the first storage
error suspends persistence for this hook's current activation before calling
onStorageError(error) with the original thrown value. An activation is one
continuous enabled period with the same key and storage reference. Suspension:
- Leaves current form values intact and stops further storage access, reads,
writes, expiry cleanup, and
clear()calls for that activation. - Does not treat a failed read as an empty entry, overwrite unread data with defaults, delete data to recover, or use an in-memory fallback. Previously saved data may remain; a failed removal does not mean an entry was cleared.
- Does not retry on edits, rerenders, or changes to callbacks, codecs, field
selection, or timeout. Changing or removing
onStorageErroralone does not resume a suspended activation. - Starts fresh when the key or
storagereference changes, after anullkey is re-enabled, or on a real remount. These transitions restore the latest stored values first, which can replace edits made while saving was suspended. - Is local to each hook instance and survives development StrictMode effect replay. Keep a custom storage object's reference stable.
The handler is synchronous and is not awaited. A handler that throws propagates
its error. Only errors from obtaining the default storage or calling storage's
getItem, setItem, and removeItem are handled. Malformed JSON, codec errors,
and errors in watch, setValue, onDataRestored, or onTimeout still propagate.
When storage works, restoration, field selection and expiration behave as usual.
For expiry, onTimeout runs before removal; a failed removal then suspends
persistence and reports the error without restoring or rewriting expired data.
clear() uses the same policy: it throws by default, or reports and suspends
when a handler is provided. The hook cannot undo side effects performed by a
custom storage implementation before it throws.
An expression such as storage: window.localStorage runs in your component,
before the hook can handle errors, even when its key is null. To defer that
getter until the hook's guarded operations, supply a stable lazy adapter and
the error handler. This TypeScript example implements the existing Storage
interface; it deliberately lets errors reach the hook instead of returning a
misleading null on failure:
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import useFormPersist from 'react-hook-form-persist';
function createLazyLocalStorage(): Storage {
return {
get length() { return window.localStorage.length; },
key: (index) => window.localStorage.key(index),
clear: () => window.localStorage.clear(),
getItem: (key) => window.localStorage.getItem(key),
setItem: (key, value) => window.localStorage.setItem(key, value),
removeItem: (key) => window.localStorage.removeItem(key)
};
}
function Form() {
const { watch, setValue } = useForm();
const [storage] = useState(createLazyLocalStorage);
useFormPersist('form', {
watch,
setValue,
storage,
onStorageError: (error) => console.warn('Saving is unavailable', error)
});
// Render your fields here.
return null;
}The hook only calls the adapter's getItem, setItem, and removeItem methods.
Creating this adapter does not read browser storage and does not create a shared
fallback cache. Each form owns its adapter and its suspension state.
The default export is a React hook. Call it unconditionally inside a component
or another hook, with the watch and setValue functions from useForm:
const { clear } = useFormPersist('form', { watch, setValue });name is a required string | null: a string identifies the storage entry,
including the empty string. Pass null to disable persistence without changing
the order of hooks. See Conditional persistence.
The reference describes the current repository source. Changes on the default branch may not yet be included in a published npm version.
config is required. TypeScript users can import the FormPersistConfig type
from react-hook-form-persist. The only required properties are watch and
setValue.
| Option | Default | Description |
|---|---|---|
watch |
Required | Called without arguments to read current form values while enabled. Normally the form's watch function; the isolated child recipe uses getValues with a separate useWatch subscription. |
setValue |
Required | The form's setValue function. Receives each selected restored field and its value, with the restoration flags below. |
storage |
window.sessionStorage |
A synchronous Storage object. The hook uses getItem, setItem, and removeItem. See Handling unavailable storage for guarded access and lazy adapters. |
include |
undefined |
Optional string[] allowlist of exact top-level field keys. Omit it to select all fields except exclusions; [] selects none. See Selecting fields. |
exclude |
[] |
Optional string[] of exact top-level keys to omit from saving and restoration. Exclusion takes precedence over inclusion. |
onDataRestored |
undefined |
Optional (data: any) => void callback after an unexpired entry is restored. Receives only selected values, without _timestamp, or {} if none were selected. Not called when no entry exists or it expires. |
validate |
false |
Optional boolean passed as shouldValidate to setValue when restoring a field. |
dirty |
false |
Optional boolean passed as shouldDirty to setValue when restoring a field. |
touch |
false |
Optional boolean passed as shouldTouch to setValue when restoring a field. |
timeout |
undefined |
Optional expiration age in milliseconds. Use a positive number. Age is checked when reading stored data, not by a background timer. Omission or 0 disables the age check. See expiration details below. |
onTimeout |
undefined |
Optional () => void callback when a read finds an expired entry. Runs before that entry is removed; expired values are not restored. |
onStorageError |
undefined |
Optional (error: unknown) => void callback. Opts into suspending the failing storage activation before reporting its first storage error. Without it, storage errors throw. See Handling unavailable storage for recovery and which errors are handled. |
serialize |
JSON.stringify |
Optional synchronous (data: Record<string, any>) => string codec for selected values and any expiration metadata. See Custom serialization. |
deserialize |
JSON.parse |
Optional synchronous (serialized: string) => Record<string, any> codec for stored strings. Field selection is applied to its result. |
When timeout is supplied (including 0), each nonempty write records a new
_timestamp using Date.now(). A truthy timeout expires an entry when its age
is strictly greater than that timeout. Expiration is checked during restoration,
including when the key is enabled again. It is not a save delay, and changing
timeout alone does not reload an entry. Preserve _timestamp in custom codecs;
it is reserved metadata and is never restored as a form field.
Callbacks and codecs are synchronous and are not awaited. Codec errors and
errors in application callbacks still propagate; onStorageError only handles
storage access failures. Keep restoration callbacks and codecs stable as
described in the usage sections to avoid unintentionally restoring old data.
The hook always returns { clear, isSynchronized }:
isSynchronized is a boolean indicating that the current enabled activation's
initial restoration check completed successfully. See
Waiting for restoration for transitions and errors.
clear is a () => void function. clear() removes this hook's current storage entry. It does not reset form
values or disable persistence, so later form changes can save another entry.
It does nothing when the key is null or its storage activation is suspended.
Removal errors throw by default, or report and suspend through onStorageError
when supplied. A failed removal does not mean the entry was cleared.
Got an idea for a new feature? Found a bug? Contributions are welcome! Please open up an issue or make a pull request.
Use a supported Node.js LTS release: Node 22.22.2+ or Node 24.15.0+
(.nvmrc selects Node 24), and pnpm 7.33.7 as pinned in package.json.
These requirements apply to development tools; the library still builds CJS,
ESM, and declarations with the existing Node 16 output target and peer ranges.
pnpm install --frozen-lockfile --ignore-scripts
pnpm run test --run
pnpm coverage
pnpm lint
pnpm types:check
pnpm buildThe test runner, DOM environment, and coverage provider are pinned in the
lockfile. The coverage command runs entirely from installed dependencies and
does not download a provider on demand. The scoped tsup>esbuild override
keeps the build tool on a version patched for
GHSA-g7r4-m6w7-qqqr.