A technically functional form is not the same as a good form. Professional form UX means users always know what is happening (loading spinners on submit), know when something succeeded (success banners), do not lose work (draft preservation in localStorage), and can use the form with a keyboard or screen reader (accessibility). These finishing touches separate a learning project from a production-quality application and take relatively little effort compared to building the form in the first place.
Loading State on Submit Button
// ── Spinner component ─────────────────────────────────────────────────────────
function Spinner({ size = "sm" }) {
const sizes = { sm: "h-4 w-4", md: "h-6 w-6" };
return (
<div className={`animate-spin rounded-full border-2 border-white
border-t-transparent ${sizes[size]}`} />
);
}
// ── Submit button with loading state ─────────────────────────────────────────
function SubmitButton({ isLoading, children, loadingText = "Saving..." }) {
return (
<button
type="submit"
disabled={isLoading}
className={`flex items-center justify-center gap-2 px-6 py-2
bg-blue-600 text-white rounded-lg font-medium
transition-all duration-150
${isLoading
? "opacity-70 cursor-not-allowed"
: "hover:bg-blue-700 active:bg-blue-800"
}`}
>
{isLoading && <Spinner size="sm" />}
{isLoading ? loadingText : children}
</button>
);
}
Note: The submit button should be
disabled={isLoading} to prevent double-submission — a common bug where the user clicks the button twice quickly, sending two identical requests. The disabled state prevents the second click. Also disable the entire form during submission by wrapping it with a fieldset disabled={isLoading} — this disables all inputs and buttons at once without needing to prop-drill the isLoading state to every field.Tip: Preserve form drafts in localStorage to protect against accidental navigation. On every significant change, save the form state:
localStorage.setItem("post-draft", JSON.stringify(form)). On component mount, load any saved draft: useState(() => JSON.parse(localStorage.getItem("post-draft")) ?? defaultForm). Clear the draft on successful submission: localStorage.removeItem("post-draft"). This one-time feature addition prevents enormous frustration when a user accidentally closes the tab mid-draft.Warning: Success messages that auto-dismiss after a timeout can cause accessibility issues if the timeout is too short — screen reader users may not hear the message before it disappears. Either keep the success message visible until the user dismisses it, or use an accessible toast library that follows WCAG success notification guidelines. For the blog application, navigating away after successful save (e.g., to the post detail page) is often cleaner than a dismissible banner.
Success Banner and Draft Persistence
import { useState, useEffect } from "react";
const DRAFT_KEY = "blog-post-draft";
function PostEditorWithDraft() {
// Load saved draft on first render (lazy initialiser)
const [form, setForm] = useState(() => {
try {
const saved = localStorage.getItem(DRAFT_KEY);
return saved ? JSON.parse(saved) : { title: "", body: "", slug: "", status: "draft", tags: [] };
} catch {
return { title: "", body: "", slug: "", status: "draft", tags: [] };
}
});
const [saved, setSaved] = useState(false);
const [isLoading, setLoading] = useState(false);
// Auto-save draft to localStorage on every change
useEffect(() => {
localStorage.setItem(DRAFT_KEY, JSON.stringify(form));
}, [form]);
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
try {
await postsApi.create(form);
localStorage.removeItem(DRAFT_KEY); // clear draft on success
setSaved(true);
setTimeout(() => setSaved(false), 5000); // auto-dismiss after 5s
} finally {
setLoading(false);
}
}
return (
<div>
{saved && (
<div role="alert" className="bg-green-50 border border-green-200 text-green-700
px-4 py-3 rounded mb-4 flex items-center gap-2">
<span>✓</span>
<span>Post saved successfully!</span>
<button type="button" onClick={() => setSaved(false)}
className="ml-auto text-green-500 hover:text-green-700">✕</button>
</div>
)}
<form onSubmit={handleSubmit} noValidate>
{/* form fields */}
<SubmitButton isLoading={isLoading}>Save Post</SubmitButton>
</form>
</div>
);
}
Accessible Form Patterns
// ── Focus first invalid field on submit ───────────────────────────────────────
import { useRef } from "react";
function AccessibleForm() {
const titleRef = useRef(null);
const bodyRef = useRef(null);
async function handleSubmit(e) {
e.preventDefault();
const errs = validate(form);
if (Object.keys(errs).length) {
setErrors(errs);
// Focus the first invalid field for keyboard accessibility
if (errs.title) titleRef.current?.focus();
else if (errs.body) bodyRef.current?.focus();
return;
}
// ...
}
return (
<form onSubmit={handleSubmit} noValidate aria-label="Create post">
<FormField label="Title" htmlFor="title" error={errors.title} required>
<Input
ref={titleRef}
id="title"
name="title"
value={form.title}
onChange={handleChange}
isInvalid={Boolean(errors.title)}
aria-describedby={errors.title ? "title-error" : undefined}
/>
</FormField>
{/* ... */}
</form>
);
}
Common Mistakes
Mistake 1 — Allowing double-submission (two identical API calls)
❌ Wrong — user clicks twice, two posts created:
<button type="submit">Submit</button> // no disabled state!
✅ Correct — disable during loading:
<button type="submit" disabled={isLoading}>Submit</button> // ✓
Mistake 2 — Storing drafts without try/catch (localStorage might be full or blocked)
❌ Wrong — throws if storage is full or in private mode:
localStorage.setItem("draft", JSON.stringify(form));
✅ Correct — wrap in try/catch:
try { localStorage.setItem("draft", JSON.stringify(form)); } catch { /* ignore */ } // ✓
Quick Reference
| Pattern | Code |
|---|---|
| Spinner on submit | <button disabled={isLoading}>{isLoading ? "..." : "Submit"}</button> |
| Save draft | localStorage.setItem(key, JSON.stringify(form)) |
| Load draft | useState(() => JSON.parse(localStorage.getItem(key)) ?? defaults) |
| Clear draft | localStorage.removeItem(key) on success |
| Focus invalid field | useRef + ref.current?.focus() |
| Accessible error | role="alert" on error message, aria-invalid on input |
| Success banner | State bool + conditional render + role="alert" |