Interview question
Build an accessible profile form
Implements labeled fields, client validation, error summaries, focus recovery, and pending submission state.
TL;DR
Implements labeled fields, client validation, error summaries, focus recovery, and pending submission state.
Semantic forms, labels, aria-describedby, focus management, validation, and pending state.
Practice the problem like a real interview: restate, reason, implement, and test.
Create name and email fields. On invalid submit, render field errors plus a summary and move focus to that summary. Disable only the submit command while saving.
Submitting blank values announces two errors, focuses the summary, and preserves entered values.
I keep values controlled, derive errors on submit, and focus a summary only when errors appear. Native form semantics carry most keyboard behavior without custom key handlers, while a form-level error keeps failed saves recoverable.
export function ProfileForm() {
const [values, setValues] = useState({ name: "", email: "" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const summaryRef = useRef<HTMLDivElement>(null);
async function submit(event: FormEvent) {
event.preventDefault();
const next = validateProfile(values);
setErrors(next);
setFormError(null);
if (Object.keys(next).length) {
requestAnimationFrame(() => summaryRef.current?.focus());
return;
}
setPending(true);
try {
await saveProfile(values);
} catch {
setFormError("Profile was not saved. Review your values and try again.");
requestAnimationFrame(() => summaryRef.current?.focus());
} finally { setPending(false); }
}
return <form onSubmit={submit} noValidate>
{(Object.keys(errors).length > 0 || formError) &&
<div ref={summaryRef} tabIndex={-1} role="alert">
{formError ?? "Please fix the highlighted fields."}
</div>}
<label htmlFor="name">Name</label>
<input id="name" value={values.name} aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
onChange={e => setValues(v => ({ ...v, name: e.target.value }))} />
{errors.name && <p id="name-error">{errors.name}</p>}
<label htmlFor="email">Email</label>
<input id="email" type="email" value={values.email} aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
onChange={e => setValues(v => ({ ...v, email: e.target.value }))} />
{errors.email && <p id="email-error">{errors.email}</p>}
<button disabled={pending}>{pending ? "Saving" : "Save"}</button>
</form>;
}
The component uses native semantics and one controlled state object. Focus moves only after failed submission, avoiding disruptive focus changes while typing.
Next in Practical UI Labs: API Field Errors