open atlas
↑ Back to track
React, zero to senior RCT · 10 · 02

Focus management: focus is application state you own

SPAs decouple focus from navigation: a route change announces nothing until you focus the new heading (tabIndex -1); dialogs trap focus, inert the background, restore to the trigger; flushSync beats focus-after-render races; deleting the focused node strands users at body.

RCT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The enterprise customer’s accessibility consultant filed a one-line bug that froze a six-figure renewal: “After Continue, NVDA goes silent.” The onboarding flow was a five-step SPA wizard. For a sighted user, clicking Continue swapped the route and the next form slid in. For a screen-reader user, the same click unmounted the button under their focus, the browser quietly dropped focus to body, and nothing was announced — the app sounded dead. Some users pressed Enter again and double-submitted; one support ticket described re-Tabbing from the page top through forty-one stops to discover that step two had, in fact, rendered. The second finding in the same audit was nastier: deleting a payee from a table. The Delete button lived inside the row it destroyed, so the press removed the focused element itself — focus fell to body, the virtual cursor beached at the top of the document, and a keyboard user cleaning up payee 38 of 40 was teleported to the header after every single deletion. Neither bug is exotic; both are the default physics of a SPA. Browsers manage focus across full page loads — new document, focus reset, title announced. The day you adopted client-side routing, you silently took over that job, and “we did nothing” compiles to “focus goes wherever the DOM diff happens to leave it.”

Route changes move focus nowhere

On a full page load the browser does three accessibility jobs for free: it resets focus to the top of the new document, announces the document title, and starts the reading order from a known place. A client-side route change does none of them. React swaps a subtree; focus either stays on whatever survived the diff (a nav link far from the new content) or — if the previously focused element unmounted — falls back to body, the screen reader says nothing, and the user’s next Tab starts from wherever the corpse was buried. The standard remedy is to treat navigation as a focus event: after the new view commits, focus its h1 given tabIndex={-1}, and update document.title. The -1 matters — it makes the heading programmatically focusable without inserting it into the Tab order, so keyboard users are not forced to stop on a non-interactive element forever after. Focusing the heading does double duty: the screen reader announces it (context: “Payment details, heading level 1”), and sequential focus now continues from the top of the new content instead of the old DOM position.

function RouteHeading({ children }) {
  const ref = useRef(null);
  useEffect(() => {
    // on mount of a new route view: announce + reposition focus
    ref.current?.focus();
  }, []);
  return <h1 tabIndex={-1} ref={ref}>{children}</h1>;
}

The alternative is a live-region route announcer (an aria-live="polite" element whose text you set to the new page name) — it announces without moving focus, which suits cases where moving focus would be disruptive, but it does not fix the “next Tab starts from nowhere” half of the problem. Production routers and frameworks ship variants of both; the part that stays your responsibility is choosing the focus target per navigation type — a wizard step wants the heading, an in-page filter update usually wants focus left alone plus a polite announcement of the result count.

Quiz

After a client-side navigation in your SPA, a screen-reader user hears nothing and their next Tab press lands on the cookie banner at the document top. What happened mechanically?

The dialog focus contract

A modal dialog is a focus contract with four clauses, and APG spells them out. On open: move focus inside — to the first sensible focusable, or to the dialog container itself for confirmation dialogs where focusing the “Delete forever” button would be a loaded gun. While open: focus is trapped — Tab from the last element wraps to the first, and the page behind is inert; the modern mechanism is the inert attribute on the background container (or the native dialog element’s showModal(), which provides top-layer rendering, Escape, and background inertness in one move). inert is the important half that hand-rolled traps forget: a Tab-key trap stops the keyboard, but without inertness a screen reader’s virtual cursor walks straight out of the dialog into the background page. On Escape: close. On close: return focus to the element that opened the dialog — store a reference to document.activeElement before you move focus, and restore it after unmount.

Restoration is the clause teams break most, and it composes badly with the other clauses: a trap without restoration means every dialog interaction ends with the user dumped at body — the trap made the dialog usable and the missing restore made closing it a cliff. The senior-grade edge case: the trigger may not exist anymore when the dialog closes (the dialog’s job was to delete the row containing the trigger). Restoration therefore needs a fallback chain — trigger if alive, else the nearest surviving container or the list heading — which is precisely the “focus is application state” claim made concrete: you are computing the next focus position from your data model, not from the DOM.

Races, deletions, and the two focus pseudo-classes

Focus can only land on a node that exists. The classic React race: an event handler calls setShowPanel(true) and then panelRef.current.focus() — but state updates are batched and asynchronous, the panel has not rendered yet, panelRef.current is null, and focus silently goes nowhere. flushSync from react-dom exists for exactly this: wrap the state update in flushSync(() => setShowPanel(true)), and React commits the DOM synchronously before the next line, so the focus() call finds the node. It is a deliberate escape hatch with a real cost — a synchronous render of the affected subtree, bypassing batching — so the rule is: flushSync for focus (and scroll, and selection) positioning after a state-driven mount, and nothing else.

Deletion is the same theorem from the other side: when the focused element unmounts, the browser moves focus to body — there is no “nearest sibling” heuristic in the platform. So compute the successor before mutating: focus the next row’s equivalent control, else the previous row’s, else the list heading with tabIndex={-1}. Your data model knows the neighbor; the DOM after deletion does not.

Two adjacent distinctions complete the toolkit. :focus matches whenever the element has focus; :focus-visible matches when the browser heuristics say an indicator should be shown — keyboard interaction yes, mouse click usually no. The modern pattern is a strong ring on :focus-visible instead of deleting outlines (the ancient outline: none crime was motivated by mouse-click rings; :focus-visible removes the motivation). And scrollIntoView is not focus: it moves pixels, not the sequential-focus position — after scrolling an element into view, the user’s next Tab still continues from the old location. If the user should interact from the new place, you focus; if they should merely see it, you scroll; doing one while meaning the other is a category error that only keyboard users experience.

Why this works

Why does the platform punt focus to body instead of doing something smarter when the focused node disappears? Because every smarter option encodes a policy the browser cannot know. Next sibling? Previous? The container? The element that replaced it in place? Each is correct in some UI and absurd in another — after deleting the last list item, “next sibling” might be the page footer. The DOM has no concept of “this node replaced that one”; only your application state knows the semantic successor. So the spec chose the only neutral fallback, body, and made successor choice an application concern. React inherits this completely: a re-render that unmounts the focused component is, to the browser, just a removal.

Quiz

A handler runs setOpen(true) and then inputRef.current.focus() on the next line, intending to focus an input inside the newly opened panel. The input does not receive focus. Why, and what is the sanctioned fix?

Recall before you leave
  1. 01
    Recite the four clauses of the dialog focus contract and the two ways teams break it.
  2. 02
    Explain the focus-after-render race and the deleted-row strand, and why both have the same root cause.
Recap

A full page load gives assistive technology three free signals — focus reset, title announcement, known reading order — and a client-side route change gives none of them, so a SPA that “does nothing” leaves focus on stale DOM or dumped at body in silence. The working pattern set: treat navigation as a focus event by focusing the new view’s heading with tabIndex of minus one (programmatically focusable, not in Tab order) and updating the title, or announce via a polite live region when moving focus would disrupt; honor the four-clause dialog contract — focus in on open, Tab trap plus background inertness while open (inert or native showModal, because a bare key trap does not stop the virtual cursor), Escape to close, and restoration to the stored trigger with a fallback chain for triggers the dialog itself destroyed. The mechanics underneath: focus can only land on committed nodes, so focus-after-state-change needs flushSync to force the commit before the focus call — a deliberate, expensive escape hatch scoped to focus, scroll, and selection; and an unmounting focused element always sends focus to body because the platform refuses to guess successors, so deletion flows compute the next focus target from the data model before mutating. Style the indicator on :focus-visible rather than deleting outlines, and keep scrollIntoView and focus distinct: one moves the viewport, the other moves the point where the keyboard lives. Every one of these is the same claim: in a SPA, focus is application state, and unmanaged state rots. Now when you see a route change go silent or a deletion strand the cursor at the page top, you have the exact checklist — heading focus, dialog contract, flushSync, precomputed successor — to find and fix it in minutes.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.