Forms and Interaction State
(Protecting user intent through validation, submission, and recovery)
Senior rule:
Forms are not input fields. Forms are user intent under failure.
Atlas has account settings, invite flows, report filters, billing forms, and saved dashboard views. Forms are where users trust the product with intent. Losing input, hiding errors, or submitting twice breaks that trust.
Conceptual Model
Every serious form has state beyond field values:
- initial value
- current value
- dirty state
- touched state
- validation state
- pending state
- server error state
- submission result
- recovery path
Validation Layers
| Layer | Purpose | Trust level |
|---|---|---|
| HTML constraints | fast native guardrails | usability only |
| client validation | immediate feedback | usability only |
| server validation | authoritative correctness | security and correctness |
| schema validation | shared contract discipline | consistency |
Client validation helps users. It does not replace server validation.
Accessible Field Pattern
type TextFieldProps = {
id: string;
label: string;
name: string;
error?: string;
hint?: string;
};
export function TextField({ id, label, name, error, hint }: TextFieldProps) {
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
return (
<div>
<label htmlFor={id}>{label}</label>
{hint ? <p id={hintId}>{hint}</p> : null}
<input
id={id}
name={name}
aria-invalid={Boolean(error)}
aria-describedby={[hintId, errorId].filter(Boolean).join(" ") || undefined}
/>
{error ? (
<p id={errorId} role="alert">
{error}
</p>
) : null}
</div>
);
}
Senior frontend engineers design the component API so accessible usage is the default.
React Form Actions and Pending State
"use client";
import { useActionState } from "react";
type FormState = { error?: string; success?: boolean };
async function updateProfile(
previousState: FormState,
formData: FormData
): Promise<FormState> {
const displayName = String(formData.get("displayName") ?? "").trim();
if (displayName.length < 2) {
return { error: "Display name must be at least 2 characters." };
}
const response = await fetch("/api/profile", {
method: "POST",
body: JSON.stringify({ displayName }),
});
if (!response.ok) return { error: "Profile could not be updated." };
return { success: true };
}
export function ProfileForm() {
const [state, formAction, isPending] = useActionState(updateProfile, {});
return (
<form action={formAction}>
<TextField
id="displayName"
name="displayName"
label="Display name"
error={state.error}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
</form>
);
}
Use pending state to prevent duplicate submissions and communicate progress.
Optimistic Updates
Optimism is a trust trade-off.
"use client";
import { useOptimistic, useTransition } from "react";
type SavedView = { id: string; name: string; saving?: boolean };
export function SavedViews({
views,
saveView,
}: {
views: SavedView[];
saveView: (name: string) => Promise<SavedView>;
}) {
const [isPending, startTransition] = useTransition();
const [optimisticViews, addOptimisticView] = useOptimistic(
views,
(current, name: string) => [
...current,
{ id: `temp-${name}`, name, saving: true },
]
);
function onSubmit(formData: FormData) {
const name = String(formData.get("name") ?? "");
addOptimisticView(name);
startTransition(async () => {
await saveView(name);
});
}
return (
<form action={onSubmit}>
<input name="name" aria-label="View name" />
<button disabled={isPending}>Save view</button>
<ul>
{optimisticViews.map((view) => (
<li key={view.id}>
{view.name} {view.saving ? "(saving)" : null}
</li>
))}
</ul>
</form>
);
}
Use optimistic UI when the action is reversible and success is likely. Avoid it for billing, permission, identity, or destructive actions unless rollback is explicit.
Dirty State and Navigation
"use client";
import { useEffect } from "react";
export function useBeforeUnloadWhenDirty(isDirty: boolean) {
useEffect(() => {
if (!isDirty) return;
function onBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
}
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [isDirty]);
}
Use this sparingly. Better still: autosave drafts or preserve user input.
Mini Case Study: Lost Billing Form
Atlas users filled a billing form, hit submit, got a server validation error, and lost half the fields.
Fix:
- preserve submitted values after server errors
- show field-level errors
- focus the first invalid field
- disable submit while pending
- log server validation failures by field
The senior issue was not validation itself. It was preserving user intent through failure.
Common Failure Modes
- Error text not connected to inputs.
- Submit buttons that can be double-clicked.
- Server errors shown only as generic toasts.
- Dirty state ignored on navigation.
- Optimistic UI without rollback.
- Validation rules duplicated inconsistently.
Review Checklist
- Are labels, hints, and errors programmatically connected?
- Does server validation preserve input?
- Is duplicate submission prevented?
- Does pending state communicate progress?
- Is dirty state handled?
- Is optimistic UI reversible?
- Does focus move to useful recovery points?
Senior Deep Dive: Preserving User Intent
Forms are not just input collection. They are user-intent systems. A senior frontend engineer treats every form as a workflow with ownership, validation, recovery, accessibility, and trust boundaries.
Intent preservation model
For every form, answer:
| Question | Why it matters |
|---|---|
| What user intent is being captured? | prevents designing fields without understanding task outcome |
| When is the intent locally valid? | separates client guidance from server authority |
| When is the intent committed? | clarifies pending, success, and rollback states |
| Can the user safely retry? | drives idempotency, duplicate submit, and timeout design |
| What data must survive navigation or refresh? | determines draft persistence and privacy risk |
| Which errors can the user fix? | shapes field-level vs form-level messaging |
| Which errors are system failures? | shapes retry, support, and incident signals |
The weakness this corrects: many engineers design form state as a set of controlled inputs. Senior engineers design a user promise: "you will not lose work, and we will tell you what happened."
Validation architecture
Validation should be layered:
| Layer | Purpose | Example |
|---|---|---|
| Input affordance | prevent obvious invalid entry | input type, mask, length hints |
| Client validation | fast guidance before submit | required field, local format check |
| Server validation | authoritative business rule | permission, uniqueness, current pricing |
| Async validation | expensive or external checks | address lookup, tax id, invite email |
| Post-submit reconciliation | resolve final state | payment accepted, export queued |
Do not duplicate complex server rules in the client unless the rule is stable, tested, and clearly owned. The client should improve user feedback, not become a second authority that can drift.
Error taxonomy for forms
Map errors before implementing UI:
| Error type | UI response |
|---|---|
| Field correction | focus or link to field, clear message, preserve input |
| Cross-field conflict | explain relationship and mark involved fields |
| Permission failure | stop the action and explain access boundary |
| Conflict/stale data | show what changed and allow refresh or merge |
| Network timeout | preserve draft, show unknown commit status if needed |
| Server failure | provide retry path and avoid blaming user |
| Unsafe duplicate | disable or dedupe submit with idempotency |
The dangerous case is timeout after a state-changing request. The UI may not know whether the server committed. Senior engineers design an "unknown result" state instead of resetting the form or blindly retrying unsafe operations.
Accessibility and interaction details
A production form contract should include visible labels, programmatic error association, summary for multi-error forms, focus movement after submit failure, preserved input after validation failure, keyboard reachable custom controls, announced pending state when meaningful, and careful use of disabled controls. Accessibility is part of correctness because a user who cannot submit or recover has lost the workflow.
Draft and privacy policy
Draft persistence is valuable and risky. Decide which fields may be persisted, whether persistence is local or server-side, when drafts expire, how drafts are cleared on logout, whether sensitive fields are excluded, and how restoration is communicated. Never add local persistence as a convenience without a data-sensitivity review.
Exercises
Exercise 1 - Form Failure Drill
Test one form with invalid input, network failure, server rejection, refresh, and duplicate submit.
Exercise 2 - Accessibility Audit
Use only keyboard and a screen reader path. Verify labels, errors, and focus.
Exercise 3 - Optimism Decision
Choose one action and decide whether optimistic UI is appropriate. Write the rollback rule.
Further Reading
- React: useActionState - https://react.dev/reference/react/useActionState
- React: useOptimistic - https://react.dev/reference/react/useOptimistic
- MDN: Client-side form validation - https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation
- WAI: Forms tutorial - https://www.w3.org/WAI/tutorials/forms/