A field error is not a focus plan
Returning structured validation from a Server Action is the easy half. The UI paints red text under a few inputs, the submit button stays focused, and anyone on a keyboard or screen reader has no reason to believe anything changed. That is the same class of bug as soft navigation that never moves focus — except it happens without leaving the page. A validation state is data. Recovery is a focus move plus an announcement.
What useActionState actually gives you
React 19's `useActionState` (and the older `useFormState` pattern) is the right shape for App Router forms: the action returns field errors, the bound action wires the form, and progressive enhancement still works when JavaScript is late or absent. Zod on the server, a typed error map on the way back, inline messages under each control — that part is settled.
None of that moves the caret, the focus ring, or the screen reader's virtual cursor. The framework re-renders the tree with new state. Orientation is your job. If you stop at "show the messages," you have built a form that fails quietly for keyboard and AT users while looking finished in a mouse-driven QA pass.
Errors under the fold are a silent failure
Contact forms, quote requests, Sanity Studio-adjacent admin tools, and checkout-adjacent account forms all share a layout habit: the submit control sits below a tall field stack. After a failed post, focus remains on that button. The first invalid field may be above the viewport. A screen reader that was parked on Submit hears nothing unless you announce it.
We have shipped the half-done version of this — red borders, correct copy, zero focus change — and only caught it when someone tabbed through after a deliberate bad submit. Mouse users scroll up hunting for red. Keyboard users press Submit again. That second click is not impatience; it is the UI refusing to point at the problem.
Put a summary at the top and focus it
When the action returns errors, render a short error summary above the form: a heading like "Fix N items," a list of links that jump to each invalid field, and `tabIndex={-1}` on the summary container. On the transition into an error state — not on every render — call `.focus()` on that summary. Screen readers announce the heading; keyboard users land where the recovery starts.
Pair that with field wiring: `aria-invalid="true"` on failing controls, `aria-describedby` pointing at the inline message id, and visible labels that do not disappear into placeholder text. The summary is the orientation cue. The inline messages are the detail. A polite live region that only repeats "There were errors" without a focus move is a weaker substitute — use it as a complement when you cannot steal focus, not as the whole plan.
This is not soft-navigation focus
We already treat App Router soft navigations as a correctness bug when focus stays on the old link. Form recovery is the same obligation on a smaller stage: the document did not unload, the route may not have changed, and the user still needs a new place to work. Moving focus to the page `h1` would be wrong here — they are not in a new view; they are mid-task.
Do not toast the validation either. A toast that says "Please fix the errors" while the fields sit unchanged trains people to dismiss chrome instead of editing inputs. Toasts are for ambient status. Field recovery belongs in the form's reading order.
How we check it before it ships
Submit the form empty with only the keyboard. Confirm focus lands on the summary (or, if you skipped the summary, on the first invalid field), the focus ring is visible, and VoiceOver or NVDA reads the error count before you tab into fields. Fix one field at a time and confirm the summary and `aria-invalid` states update without trapping focus in a dead node.
Repeat with JavaScript disabled if the action is progressively enhanced: full page re-render should still put an error summary at the top of the response HTML, not only in a client island that never hydrated. View source on the error response the way we view source for SSR truth — if the summary is missing from the HTML, the no-JS path never recovered.