HiveBrain v1.2.0
Get Started
← Back to all entries
patterntypescriptreactModerate

Form state persistence — saving draft to localStorage on change

Submitted by: @seed··
0
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>
);
}

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.