patterntypescriptreactModerate
Form state persistence — saving draft to localStorage on change
Viewed 0 times
form draftlocalStorage persistencerestore formwatch subscriptiondraft keyform data losscheckout persistence
Problem
Users filling out long forms (registrations, applications, checkout) lose all their progress if they accidentally close the tab, refresh, or experience a session timeout. Saving a draft to localStorage restores their progress on return.
Solution
Persist form values to localStorage on change; restore them on mount:
import { useForm } from 'react-hook-form';
import { useEffect } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const DRAFT_KEY = 'checkout-draft';
const checkoutSchema = z.object({
name: z.string().min(1),
address: z.string().min(1),
city: z.string().min(1),
});
type CheckoutForm = z.infer<typeof checkoutSchema>;
function loadDraft(): Partial<CheckoutForm> {
try {
const raw = localStorage.getItem(DRAFT_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function CheckoutForm() {
const draft = loadDraft();
const { register, handleSubmit, watch, reset } = useForm<CheckoutForm>({
resolver: zodResolver(checkoutSchema),
defaultValues: {
name: draft.name ?? '',
address: draft.address ?? '',
city: draft.city ?? '',
},
});
// Persist on every change (debounced to every 500ms in production)
useEffect(() => {
const sub = watch((values) => {
localStorage.setItem(DRAFT_KEY, JSON.stringify(values));
});
return () => sub.unsubscribe();
}, [watch]);
const onSubmit = async (data: CheckoutForm) => {
await submitOrder(data);
localStorage.removeItem(DRAFT_KEY); // clear draft on success
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} placeholder="Full name" />
<input {...register('address')} placeholder="Address" />
<button type="submit">Place Order</button>
</form>
);
}
import { useForm } from 'react-hook-form';
import { useEffect } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const DRAFT_KEY = 'checkout-draft';
const checkoutSchema = z.object({
name: z.string().min(1),
address: z.string().min(1),
city: z.string().min(1),
});
type CheckoutForm = z.infer<typeof checkoutSchema>;
function loadDraft(): Partial<CheckoutForm> {
try {
const raw = localStorage.getItem(DRAFT_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function CheckoutForm() {
const draft = loadDraft();
const { register, handleSubmit, watch, reset } = useForm<CheckoutForm>({
resolver: zodResolver(checkoutSchema),
defaultValues: {
name: draft.name ?? '',
address: draft.address ?? '',
city: draft.city ?? '',
},
});
// Persist on every change (debounced to every 500ms in production)
useEffect(() => {
const sub = watch((values) => {
localStorage.setItem(DRAFT_KEY, JSON.stringify(values));
});
return () => sub.unsubscribe();
}, [watch]);
const onSubmit = async (data: CheckoutForm) => {
await submitOrder(data);
localStorage.removeItem(DRAFT_KEY); // clear draft on success
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} placeholder="Full name" />
<input {...register('address')} placeholder="Address" />
<button type="submit">Place Order</button>
</form>
);
}
Why
loadDraft reads from localStorage synchronously during initial render so defaultValues includes any previously saved data. The watch subscription updates localStorage on each change. Clearing the draft after successful submission prevents stale data from appearing on the next visit.
Gotchas
- Parse localStorage values defensively — corrupted JSON from a previous bug will cause JSON.parse to throw
- Do not persist sensitive data (passwords, card numbers, SSNs) in localStorage
- localStorage is synchronous and blocks the main thread for large objects — debounce writes or use an async storage library
- If the form schema changes between sessions, old drafts may fail validation — consider versioning the draft key
Revisions (0)
No revisions yet.