Skip to content

About

Persist and populate react-hook-form form using storage of your choice

Topics

Resources

Stars

184 stars

Watchers

3 watching

Forks

Latest commit

Β 

History

77 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

react-hook-form-persist

package version package downloads standard-readme compliant package license make a pull request

Persist and populate react-hook-form form using storage of your choice

πŸ“– Table of Contents

βš™οΈ Install

Install the package locally within you project folder with your package manager:

With npm:

npm install react-hook-form-persist

With yarn:

yarn add react-hook-form-persist

With pnpm:

pnpm add react-hook-form-persist

πŸ“– Usage

This 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>
  );
}

Additional examples

Persist all form fields:

useFormPersist('form', {watch, setValue});

Persist all form fields except password:

useFormPersist('form', {watch, setValue, exclude: ['password']});

Default values

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.

Isolating persistence renders

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.

Conditional persistence

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.

Waiting for restoration

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 onDataRestored callback 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 onTimeout returns and removal succeeds. The existing timeout behavior still prevents unchanged defaults from immediately recreating the entry.
  • Changing the key or storage reference makes the flag false immediately, before the new target is checked. Passing null keeps it false; re-enabling starts a new check, even for the same key. A real remount also starts false.
  • Failed initial storage access, reads, or expiry removal do not become ready. With onStorageError, the activation remains suspended and false until 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.

Selecting fields

Persist only selected fields with the optional include allowlist:

useFormPersist('form', {watch, setValue, include: ['email']});
  • Omit include to persist all fields except those in exclude.
  • Both options match exact top-level keys in the form values. Including profile persists its whole nested object; profile.email does not select a nested path.
  • exclude takes precedence when a field appears in both lists.
  • include: [] selects no fields. No fields are restored or written, and onDataRestored receives {} when an unexpired stored entry is read.
  • Selection applies after deserialization, before setValue and onDataRestored, and before serialization. _timestamp remains 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.

Restoring arrays

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.

Custom serialization

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 inputs

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.

Handling unavailable storage

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 onStorageError alone does not resume a suspended activation.
  • Starts fresh when the key or storage reference changes, after a null key 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.

πŸ“š API

useFormPersist

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.

Configuration

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.

Return value

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.

πŸ’¬ Contributing

Got an idea for a new feature? Found a bug? Contributions are welcome! Please open up an issue or make a pull request.

Development

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 build

The 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.

πŸͺͺ License

MIT Β© Tiaan du Plessis

About

Persist and populate react-hook-form form using storage of your choice

Topics

Resources

Stars

184 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages