Forms break trust when they drop state or hide errors.
A user fills out a 10-field form, hits submit, the page reloads, and the form is empty. They are gone.
A user makes a typo in an email field, the form silently rejects it, and they do not know why. They abandon checkout.
High-stakes forms (signups, checkouts, medical records) demand obsessive attention to state preservation and error clarity.
Reduce State Loss
Autosave Drafts
Save form state to local storage or the server as the user types:
'use client';
import { useEffect, useState } from 'react';
function RegistrationForm() {
const [formData, setFormData] = useState(() => {
// Restore from local storage on mount
const saved = localStorage.getItem('registrationDraft');
return saved ? JSON.parse(saved) : { name: '', email: '', password: '' };
});
// Autosave to local storage
useEffect(() => {
const timer = setTimeout(() => {
localStorage.setItem('registrationDraft', JSON.stringify(formData));
}, 500); // Debounce writes
return () => clearTimeout(timer);
}, [formData]);
return (
<form>
<input
name="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
{/* Other fields */}
</form>
);
}
For high-stakes flows, also save to the server:
"use server";
export async function saveDraft(formData: FormData) {
const userId = await getCurrentUserId(); // From session
await db.draft.upsert({
where: { userId },
create: { userId, data: formData },
update: { data: formData },
});
}
Persist Across Reloads
Use form IDs to track state:
<form id="registration-form-v1">
<!-- Fields -->
</form>
If the user accidentally refreshes, restore their work:
useEffect(() => {
const saved = localStorage.getItem("form-registration-form-v1");
if (saved) {
const formElement = document.getElementById("registration-form-v1");
const data = JSON.parse(saved);
Object.entries(data).forEach(([name, value]) => {
const input = formElement?.querySelector(`[name="${name}"]`);
if (input) input.value = value;
});
}
}, []);
Keep IDs Stable
If a field ID changes, the form resets. Keep IDs stable across deployments:
<!-- Good: ID never changes -->
<input id="user-email-field" name="email" />
<!-- Bad: ID changes when you refactor -->
<input id="field-2" name="email" />
Validate Where It Matters
Inline Validation on Blur
Show errors as the user leaves a field, not as they type:
function EmailInput() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
function handleBlur() {
if (!email.includes('@')) {
setError('Invalid email');
} else {
setError('');
}
}
return (
<div>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={handleBlur}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? 'email-error' : undefined}
/>
{error && <p id="email-error" role="alert">{error}</p>}
</div>
);
}
Aggregate Summary on Submit
On submit, validate everything and show a summary of errors:
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const result = registrationSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
// Show all errors at the top
setErrors(result.error.flatten().fieldErrors);
// Scroll to first error
document.querySelector('[aria-invalid="true"]')?.scrollIntoView();
return;
}
// Submit
await submitForm(result.data);
}
Server Errors Mapped to Fields
When the server rejects the form (duplicate email, username taken), map errors back to fields:
"use server";
import { z } from "zod";
export async function register(input: unknown) {
const parsed = registrationSchema.parse(input);
// Check if email already exists
const existing = await db.user.findUnique({
where: { email: parsed.email },
});
if (existing) {
return {
error: {
email: "This email is already registered",
},
};
}
await db.user.create(parsed);
return { success: true };
}
Client receives the error and shows it inline:
const result = await register(formData);
if (result.error) {
setFieldErrors(result.error);
}
Operations: Reliability at Scale
Pessimistic Updates for Critical Writes
A pessimistic update waits for the server to confirm before updating the client.
Use this for critical operations (charging a card, deleting an account, transferring funds):
async function chargeCard(amount: number) {
try {
const result = await stripe.paymentIntents.create({ amount });
setPaymentStatus("success");
} catch (err) {
setPaymentStatus("failed");
setError(err.message);
}
}
The UI does not change until the server confirms. If it fails, the user knows immediately.
Optimistic Updates for Non-Critical
For non-critical writes (liking, toggling settings), update optimistically:
async function toggleNotifications() {
// Update UI immediately
setNotificationsEnabled(!notificationsEnabled);
try {
// Confirm with server
await fetch("/api/user/notifications", {
method: "PUT",
body: JSON.stringify({ enabled: !notificationsEnabled }),
});
} catch (err) {
// Rollback on failure
setNotificationsEnabled(notificationsEnabled);
setError("Failed to save settings");
}
}
Idempotency Keys
Prevent double-submits with idempotency keys:
async function handleSubmit() {
const idempotencyKey = crypto.randomUUID();
setSubmitting(true);
try {
const result = await fetch("/api/orders", {
method: "POST",
headers: {
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(formData),
});
// ...
} finally {
setSubmitting(false);
}
}
Server stores the key and response. If the same key arrives again, return the cached response:
"use server";
async function createOrder(input: unknown, idempotencyKey: string) {
// Check cache
const cached = await cache.get(`order:${idempotencyKey}`);
if (cached) return cached;
// Create order
const order = await db.order.create(input);
// Cache the response
await cache.set(`order:${idempotencyKey}`, order, { ttl: 3600 });
return order;
}
Log Form Abandonment
Track where users drop off:
const handleFieldChange = (e: React.ChangeEvent<HTMLInputElement>) => {
analytics.track("form_field_updated", {
formId: "registration",
fieldName: e.target.name,
isComplete: fieldsCompleted,
});
};
window.addEventListener("beforeunload", () => {
analytics.track("form_abandoned", {
formId: "registration",
fieldsCompleted,
lastFieldUpdated,
});
});