Skip to main content

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

LayerPurposeTrust level
HTML constraintsfast native guardrailsusability only
client validationimmediate feedbackusability only
server validationauthoritative correctnesssecurity and correctness
schema validationshared contract disciplineconsistency

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:

QuestionWhy 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:

LayerPurposeExample
Input affordanceprevent obvious invalid entryinput type, mask, length hints
Client validationfast guidance before submitrequired field, local format check
Server validationauthoritative business rulepermission, uniqueness, current pricing
Async validationexpensive or external checksaddress lookup, tax id, invite email
Post-submit reconciliationresolve final statepayment 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 typeUI response
Field correctionfocus or link to field, clear message, preserve input
Cross-field conflictexplain relationship and mark involved fields
Permission failurestop the action and explain access boundary
Conflict/stale datashow what changed and allow refresh or merge
Network timeoutpreserve draft, show unknown commit status if needed
Server failureprovide retry path and avoid blaming user
Unsafe duplicatedisable 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