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 storageisRestored: boolean indicating if data was restored on mountlastSaved: timestamp of last successful saveisDirty: boolean indicating if current state differs from initialsize: approximate size in bytes of persisted data
Persistence control:
clear(): removes persisted data without affecting current statereset(): restores to initial state and clears storageforceSave(): saves immediately without waiting for debouncepause()/resume(): pause and resume automatic persistencerevert(): restores to last persisted value, discarding unsaved changes
History navigation (if history is enabled):
undo()/redo(): navigate through historycanUndo/canRedo: boolean indicating if states are availablehistoryIndex/historyLength: position and size of history
Utilities:
getPersistedValue(): gets persisted value without affecting statewithClear(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.