ES
$ cat ~/articles/react-form-autosave.md ← back to articles

A user spends ten minutes filling out a detailed contact form. They write their problem precisely, attach context, review everything. Then they get a Slack notification, click on it, and accidentally close the form tab. They come back, open the page again, and the form is completely empty. The user is not going to write everything again. They close the page and never return.

This scenario happens constantly. Checkout forms where the user loses the shipping address. Registration forms with twenty fields that need to be filled from scratch. Long surveys where progress disappears. Text editors where the draft vanishes.

The obvious solution is to persist form state in localStorage. Every time the user types something, you save the state. When they return, you restore it. Sounds simple until you start implementing it.

The problem with existing solutions

react-hook-form-persist is the most well-known option, but it's coupled to react-hook-form. If you use Formik, a custom solution, or just useState, you can't use it. The package hasn't been updated since 2021 and has known bugs where default values overwrite restored data after a refresh.

use-local-storage-state provides a useState replacement with localStorage synchronization. It works well for generic state, but lacks form-specific features. There's no field exclusion for sensitive data like passwords or card numbers. No debounce, so every keystroke triggers a localStorage write. No data expiration, no undo/redo, no cross-tab synchronization.

redux-persist and Zustand persistence solutions require adopting a global state management library. For a contact form or checkout page, adding Redux just to persist fields is unnecessary complexity. Plus, the Redux ecosystem adds ~10KB to the bundle.

The alternative many developers choose is writing a custom hook. Every tutorial shows a different approach, most with subtle bugs. Missing SSR checks that cause hydration errors. No debounce, so localStorage gets hammered with every keystroke. No handling for storage quota errors. No consideration for excluding sensitive fields.

Why I created this library

I needed form persistence for several projects with different requirements. One used react-hook-form, another used Formik, another used simple controlled inputs. I didn't want three different solutions or to couple myself to any specific form library.

The requirements were clear: it should work with any form approach, have zero dependencies to minimize bundle size, include configurable debounce, allow excluding sensitive fields, support data expiration, work with SSR without hydration errors, and be easy to test.

I added features I had manually implemented in previous projects: undo/redo so users can undo changes, cross-tab synchronization for forms open in multiple tabs, schema migrations for when form structure changes between versions, and GDPR compliance with the option to disable persistence until consent is obtained.

The result is react-form-autosave: a library that replaces useState with automatic persistence. The core is under 2KB gzipped. Optional features like history and sync are separate imports that only increase the bundle if you use them.

Installation and basic usage

npm install react-form-autosave

The simplest case is replacing useState with useFormPersist. The hook accepts a unique key to identify the form in storage, followed by the initial state:

import { useFormPersist } from 'react-form-autosave';

function ContactForm() {
  const [formData, setFormData, { clear }] = useFormPersist('contact-form', {
    name: '',
    email: '',
    message: '',
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    await submitToServer(formData);
    clear(); // Remove persisted data after successful submission
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={formData.name} onChange={handleChange} />
      <input name="email" value={formData.email} onChange={handleChange} />
      <textarea name="message" value={formData.message} onChange={handleChange} />
      <button type="submit">Send</button>
    </form>
  );
}

With this minimal setup, form data is automatically saved to localStorage 500 milliseconds after the user stops typing. When the user returns to the page, their previous input is automatically restored.

The API is identical to useState. The first element is the current state, the second is the setter that accepts a value or updater function. The third element is an object with additional actions like clear, undo, redo, and properties like isPersisted and lastSaved.

How persistence works

Each state change triggers a debounced save operation. The default debounce is 500ms, configurable. This means if the user types "hello", there aren't 5 writes to localStorage (one per letter), but a single write 500ms after the last letter.

Data is saved with metadata: last modification timestamp and schema version. The timestamp enables expiration. The version enables migrations when form structure changes.

Keys in localStorage have the rfp: prefix by default to avoid collisions. A form with key contact-form is saved as rfp:contact-form.

In server-side rendering environments, the library detects it's on the server and skips all storage operations. Hydration occurs correctly on the client without errors.

Configuration options

Storage and timing

// Use sessionStorage instead of localStorage
useFormPersist('form', initialState, { storage: 'sessionStorage' });

// Change debounce delay
useFormPersist('form', initialState, { debounce: 1000 }); // 1 second

// Automatic expiration
useFormPersist('form', initialState, { expiration: 60 }); // Expires in 1 hour

Storage can be localStorage (default), sessionStorage, memory (for testing), or a custom adapter implementing getItem, setItem, and removeItem.

Sensitive field exclusion

Fields like passwords, card numbers, or CVV should never be persisted in localStorage. The exclude option accepts an array of field names that are stripped before saving:

useFormPersist('checkout', initialState, {
  exclude: ['cardNumber', 'cvv', 'password'],
});

Excluded fields remain in component state but aren't saved to storage. When the user returns, these fields will be empty while the rest is restored.

Validation before saving

useFormPersist('form', initialState, {
  validate: (data) => data.email.includes('@'),
});

If the validate function returns false, the save is skipped. Useful to avoid persisting incomplete or invalid data.

Data transformation

useFormPersist('form', initialState, {
  beforePersist: (data) => ({
    ...data,
    lastModified: Date.now(),
  }),
});

The beforePersist function transforms data before saving. The transformed data is what gets persisted, while original data remains in component state.

Schema versioning and migrations

When you change your form structure between application versions, saved data may be incompatible. The version option assigns a version number to your data schema. The migrate option converts data from older versions to the current version:

useFormPersist('user-profile', initialState, {
  version: 2,
  migrate: (oldData, oldVersion) => {
    if (oldVersion === 1) {
      // Version 1 had separate firstName and lastName
      // Version 2 combines them into fullName
      return {
        ...oldData,
        fullName: `${oldData.firstName} ${oldData.lastName}`,
      };
    }
    return oldData;
  },
});

When the hook mounts and finds data with an old version, it runs the migration function before restoring. This allows evolving form structures without losing user data.

Undo and redo

Enabling history allows users to navigate through their changes:

function Editor() {
  const [content, setContent, actions] = useFormPersist('editor', { text: '' }, {
    history: { enabled: true, maxHistory: 100 },
  });

  return (
    <div>
      <div>
        <button onClick={actions.undo} disabled={!actions.canUndo}>
          Undo
        </button>
        <button onClick={actions.redo} disabled={!actions.canRedo}>
          Redo
        </button>
        <span>
          Change {actions.historyIndex + 1} of {actions.historyLength}
        </span>
      </div>
      <textarea
        value={content.text}
        onChange={(e) => setContent({ text: e.target.value })}
      />
    </div>
  );
}

History is kept in memory, not in localStorage. maxHistory limits how many states are saved to avoid excessive memory consumption. The canUndo and canRedo properties indicate if there are states available in each direction.

Cross-tab synchronization

When the user has the same form open in multiple tabs, changes can sync automatically:

const [formData, setFormData] = useFormPersist('shared-doc', initialState, {
  sync: {
    enabled: true,
    strategy: 'latest-wins',
    onSync: (data, source) => {
      showNotification('Form updated from another tab');
    },
  },
});

Synchronization uses BroadcastChannel API when available, with a fallback to storage events for older browsers. The latest-wins strategy means the most recent change overwrites. For more complex scenarios, you can provide a custom conflictResolver:

sync: {
  enabled: true,
  conflictResolver: (local, remote) => {
    // Merge arrays, prefer remote for other fields
    return {
      ...remote,
      tags: [...new Set([...local.tags, ...remote.tags])],
    };
  },
}

GDPR compliance

To comply with data protection regulations, only enable persistence after obtaining user consent:

function ConsentAwareForm() {
  const [consent, setConsent] = useState(loadConsentFromCookie());

  const [formData, setFormData, actions] = useFormPersist('form', initialState, {
    enabled: consent,
  });

  const handleRevokeConsent = () => {
    actions.clear();
    setConsent(false);
  };

  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={consent}
          onChange={(e) => {
            setConsent(e.target.checked);
            if (!e.target.checked) actions.clear();
          }}
        />
        Save my progress locally
      </label>
      {/* form fields */}
    </div>
  );
}

When enabled is false, the hook behaves like a regular useState with no storage operations. The clearGroup function with empty prefix clears all data stored by the library, implementing the right to erasure.

Multi-step forms

For forms split across multiple steps or pages, persist each step independently with related keys:

import { useFormPersist, clearGroup } from 'react-form-autosave';

function WizardStep1() {
  const [data, setData] = useFormPersist('wizard:step1', step1Initial);
  // ...
}

function WizardStep2() {
  const [data, setData] = useFormPersist('wizard:step2', step2Initial);
  // ...
}

function WizardComplete() {
  const handleComplete = async () => {
    await submitAllSteps();
    // Clear all wizard steps at once
    const clearedCount = clearGroup('wizard');
    console.log(`Cleared ${clearedCount} form(s)`);
  };
  // ...
}

The clearGroup function accepts a prefix and removes all keys starting with that prefix. clearGroup('wizard') removes wizard:step1, wizard:step2, etc.

Save status indicator

The AutoSaveIndicator component displays the current persistence status:

import { useFormPersist, AutoSaveIndicator } from 'react-form-autosave';

function Form() {
  const [data, setData, { lastSaved }] = useFormPersist('form', initialState);

  return (
    <form>
      <AutoSaveIndicator
        lastSaved={lastSaved}
        savedText="Saved"
        savingText="Saving..."
        notSavedText="Not saved"
        showTimestamp={true}
      />
      {/* form fields */}
    </form>
  );
}

The component accepts props to customize texts, show timestamp, and apply custom styles.

Available actions

The third element of the hook is an object with methods and properties:

State information:

  • isPersisted: boolean indicating if data exists in storage
  • isRestored: boolean indicating if data was restored on mount
  • lastSaved: timestamp of last successful save
  • isDirty: boolean indicating if current state differs from initial
  • size: approximate size in bytes of persisted data

Persistence control:

  • clear(): removes persisted data without affecting current state
  • reset(): restores to initial state and clears storage
  • forceSave(): saves immediately without waiting for debounce
  • pause() / resume(): pause and resume automatic persistence
  • revert(): restores to last persisted value, discarding unsaved changes

History navigation (if history is enabled):

  • undo() / redo(): navigate through history
  • canUndo / canRedo: boolean indicating if states are available
  • historyIndex / historyLength: position and size of history

Utilities:

  • getPersistedValue(): gets persisted value without affecting state
  • withClear(handler): wraps a handler to automatically clear after successful execution

Testing

The library exports utilities to simplify testing forms with persistence:

import {
  createMockStorage,
  seedPersistedData,
  getPersistedData,
  clearTestStorage,
  waitForPersist,
} from 'react-form-autosave/testing';

beforeEach(() => {
  clearTestStorage();
});

it('should restore persisted data on mount', () => {
  seedPersistedData('my-form', { name: 'John', email: 'john@test.com' });

  const { result } = renderHook(() =>
    useFormPersist('my-form', { name: '', email: '' })
  );

  expect(result.current[0].name).toBe('John');
  expect(result.current[2].isRestored).toBe(true);
});

it('should persist changes after debounce', async () => {
  const { result } = renderHook(() =>
    useFormPersist('my-form', { name: '' }, { debounce: 100 })
  );

  act(() => {
    result.current[1]({ name: 'Jane' });
  });

  await waitForPersist(150);

  expect(getPersistedData('my-form')).toEqual({ name: 'Jane' });
});

The seedPersistedData and getPersistedData functions allow pre-populating and verifying storage. waitForPersist waits for the debounce delay. createMockStorage creates a mock adapter to verify calls.

Tree-shaking and bundle size

The library is designed for optimal tree-shaking. The core is under 2KB gzipped. Optional features are separate imports:

// Core (always needed)
import { useFormPersist } from 'react-form-autosave';

// Optional modules
import { useHistory } from 'react-form-autosave/history';
import { useSync } from 'react-form-autosave/sync';
import { FormPersistDevTools } from 'react-form-autosave/devtools';
import { createMockStorage } from 'react-form-autosave/testing';

If you only use basic persistence, the additional modules aren't included in your bundle.

Comparison with alternatives

Feature react-form-autosave react-hook-form-persist use-local-storage-state redux-persist
Framework agnostic Yes No (react-hook-form only) Yes No (Redux only)
Zero dependencies Yes No Yes No
Bundle size <2KB ~1KB ~1KB ~10KB
Debounce Yes No No No
Field exclusion Yes Yes No No
Data expiration Yes Yes No No
Undo/redo Yes No No No
Cross-tab sync Yes No Yes Requires redux-state-sync
Schema migrations Yes No No Yes
TypeScript Yes Partial Yes Yes
SSR support Yes Limited Yes Yes
Testing utilities Yes No No No
Actively maintained Yes Last update 2021 Yes Yes

When to use this library

Use react-form-autosave when you need to persist form state without adopting a global state management solution, when you want form-specific features like field exclusion and debounce out of the box, when bundle size matters and you want to pay only for features you use, or when you need undo/redo or cross-tab sync without writing custom code.

Consider alternatives if you already use Redux and want to persist your entire store (use redux-persist), if you use react-hook-form and only need basic persistence (use react-hook-form-persist), or if you need to persist non-form state across your application (use use-local-storage-state or a state management solution with persistence).

Browser support

The library works in all modern browsers that support localStorage and sessionStorage: Chrome 80+, Firefox 75+, Safari 13+, Edge 80+. For cross-tab synchronization, BroadcastChannel API is used where available, with a fallback to storage events for broader compatibility.

In environments where storage is unavailable, such as some privacy-focused browser configurations or when storage quota is exceeded, the library falls back to in-memory storage and continues to function without persistence.

Test coverage

The library maintains 100% test coverage across all metrics. The test suite includes 392 tests covering all functionality.

Metric Coverage
Statements 100%
Branches 100%
Functions 100%
Lines 100%

The package is published on NPM as react-form-autosave. Source code is on github.com/686f6c61/react-form-autosave. Interactive demo at react-form-autosave.onrender.com.