Form UX Patterns — Loading States, Success Messages and Accessibility

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"

🧠 Test Yourself

A user writes a long post, accidentally closes the tab, then reopens the site. With draft preservation, what should happen and how is it implemented?