Polish: Error Focus, Loading States, and Accessibility
The capstone form works. Now make it feel finished. Accessible error handling, proper loading feedback, and keyboard-friendly controls are the difference between a form that functions and one that people can actually use.
Auto-Focus the First Error
When validation fails on submit, the user's eye goes to the button, not to the field that caused the problem. Programmatically moving focus to the first broken field fixes that immediately.
function focusFirstError() {
const firstInvalid = document.querySelector<HTMLElement>(
'[aria-invalid="true"]'
)
firstInvalid?.focus()
}
Call this after a failed submit. One approach: check canSubmit after calling handleSubmit and focus if validation failed:
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
// After submit attempt, if validation failed, focus the first error
requestAnimationFrame(() => {
if (!form.state.canSubmit) {
focusFirstError()
}
})
}}
>
This works as long as each field's <input> sets aria-invalid when it has errors — which you should be doing anyway for accessibility (as shown below).
Accessible Error Messages
Screen readers need explicit connections between inputs and their error messages. aria-invalid tells assistive technology the field is in an error state. aria-describedby points to the element containing the message. role="alert" makes the error announce itself when it appears without requiring the user to navigate to it.
<form.Field
name="email"
children={(field) => {
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
aria-invalid={hasError}
aria-describedby={hasError ? 'email-error' : undefined}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{hasError && (
<p id="email-error" role="alert" style={{ color: 'red' }}>
{field.state.meta.errors.join(', ')}
</p>
)}
</div>
)
}}
/>
Apply this pattern to every field in the capstone. The id on the error element and the aria-describedby on the input must match. If the error is not showing, aria-describedby should be undefined rather than pointing to a non-existent element.
Loading States During Async Validation
When a field is running async validation — checking whether a username is taken, for example — the user needs to know something is happening. field.state.meta.isValidating is true while any async validator is in flight.
<form.Field
name="username"
children={(field) => (
<div>
<label htmlFor="username">Username</label>
<input
id="username"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.isValidating && (
<span aria-live="polite">Checking availability...</span>
)}
</div>
)}
/>
aria-live="polite" tells screen readers to announce the status message when the user pauses, without interrupting whatever they're doing right now.
Disabling the Form During Submission
Individual disabled attributes scattered across inputs are easy to miss. The cleaner approach is a single <fieldset> wrapping all your fields. When disabled is set on a fieldset, every form control inside it is disabled — no extra props needed.
<fieldset disabled={form.state.isSubmitting}>
{/* all form fields */}
</fieldset>
Combined with the submit button already reacting to isSubmitting, this gives you complete lockout with minimal code. Nothing is clickable, nothing is editable, and the user can see clearly that work is in progress.
Keyboard Navigation
Forms that only work with a mouse aren't accessible. Two things to check in the capstone:
First, every button that isn't a submit button needs type="button". Without it, clicking "Add Interest" or "Remove" submits the form. This came up in the array fields lesson but is worth reinforcing — it also matters for keyboard users who navigate to the button and press Enter.
<button type="button" onClick={() => field.pushValue('')}>
Add Interest
</button>
<button type="button" onClick={() => field.removeValue(i)}>
Remove
</button>
Second, make sure focus order follows reading order. Don't use tabindex values greater than 0 to reorder focus — fix the DOM order instead. The natural tab order through step one, step two, and step three should flow logically without any manual intervention.
Wrapping Up
That's 20 lessons and the full shape of TanStack Form:
- Part 1 (lessons 1–5): core concepts — form setup, field state, the form lifecycle
- Part 2 (lessons 6–9): validation — sync, async, form-level, and Zod schema validation
- Part 3 (lessons 10–15): advanced patterns — array fields, linked fields, listeners, custom components, UI library integration, and DevTools
- Part 4 (lessons 16–20): the capstone — a multi-step account form built from scratch and polished end to end
The TanStack Form documentation at tanstack.com/form/latest/docs/overview goes deeper on every API touched here. The type system is thorough and the docs reflect that.
From here, the capstone is yours to extend. Add a fourth step. Replace the fake createAccount call with a real API. Add animations between steps using Framer Motion. Wire up a server-side adapter for form actions. The foundation is solid — build on it.