Focus Management in React With Refs and Effects
Permalink to "Focus Management in React With Refs and Effects"Focus management in React means deciding, after each state change, where keyboard focus should be — and making React put it there. React’s rendering model makes the decision easy to forget: when a list re-renders with new keys, when a component unmounts, or when a route changes, the focused DOM node can be destroyed and focus silently falls back to <body>. Keyboard and screen reader users then have to start again from the top of the page.
This page covers the React primitives for focus — refs, effects, keys — and a hook that restores focus by identity after data changes. It belongs to focus management in single-page apps.
Spec reference
Permalink to "Spec reference"The DOM HTMLElement.focus() method moves focus to an element that is focusable: interactive elements, and any element with a tabindex attribute. tabIndex={-1} makes an element programmatically focusable without adding it to the tab order, which is how headings, captions and containers become focus targets.
React-specific rules that matter:
- Refs are populated during commit. Calling
.focus()during render does nothing useful; call it inuseEffectoruseLayoutEffect, or in an event handler after state is set and committed. - React preserves a DOM node across renders only if its component type and
keyare unchanged. A changed key destroys the node — and its focus. autoFocusin React callsfocus()on mount, but only on mount; it does not restore focus after re-renders.
Criteria in play: SC 2.4.3 Focus Order (focus must move in a meaningful order and not jump to the start of the page), SC 2.4.7 Focus Visible, and SC 3.2.1 On Focus (moving focus must not itself trigger a change of context).
When to move focus — and when not to
Permalink to "When to move focus — and when not to"Move focus when the element the user was on no longer exists or no longer makes sense: a deleted row, a closed dialog, a replaced page of results, a route change. Move it to the nearest element that orients the user.
Do not move focus to announce something. If the user sorts a table, focus should stay on the sort button; the result is announced through a live region. Moving focus to the table on every sort forces the user back to the header to sort again.
The misapplication to name is using autoFocus or a mount effect to “restore” focus after re-renders. It fires once, when the component first mounts, and never again; on data refresh the row remounts (if keys changed) and focuses itself — sometimes a different row from the one the user was on.
Annotated code example
Permalink to "Annotated code example"// useFocusRestore: remember WHICH item had focus, restore it after a data change
import { useLayoutEffect, useRef } from 'react';
export function useFocusRestore(items, getId) {
const container = useRef(null);
const lastFocusedId = useRef(null);
// Track focus by identity, not by DOM node
const onFocusCapture = (e) => {
const el = e.target.closest('[data-focus-id]');
lastFocusedId.current = el ? el.dataset.focusId : null;
};
// After each commit, if focus fell to body, put it back by id (SC 2.4.3)
useLayoutEffect(() => {
if (document.activeElement !== document.body || !lastFocusedId.current) return;
const target = container.current?.querySelector(
`[data-focus-id="${CSS.escape(lastFocusedId.current)}"]`);
if (target) target.focus();
}, [items]);
return { container, onFocusCapture };
}
function InvoiceList({ invoices }) {
const { container, onFocusCapture } = useFocusRestore(invoices, (i) => i.id);
return (
<ul ref={container} onFocusCapture={onFocusCapture}>
{invoices.map((inv) => (
// Stable key: React keeps the same <li> across refreshes when possible
<li key={inv.id}>
<a href={`/invoices/${inv.id}`} data-focus-id={inv.id}>{inv.id}</a>
</li>
))}
</ul>
);
}
// Moving focus to a heading after replacing a page of results
const heading = useRef(null);
useEffect(() => {
if (pageChangedByUser) heading.current?.focus(); // tabIndex={-1} on the h2
}, [page]);
// <h2 ref={heading} tabIndex={-1}>Invoices, page {page}</h2>
useLayoutEffect runs before the browser paints, so restoration happens before the user or the screen reader notices focus was lost. useEffect also works but can produce a brief flash of lost focus that some screen readers announce.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected behaviour | Failure indicator |
|---|---|---|
| Data refresh while a row link has focus | Focus stays on the same record’s link | Next Tab goes to the skip link at the top |
| Page change by user | Focus moves to the results heading; heading read | Focus stays on “Next” at the bottom of the old page |
| Sort | Focus stays on the sort button | Focus jumps into the table |
| Row deleted | Focus to a neighbour (see the deletion guide) | Focus on body |
Heading with tabIndex={-1} focused |
Heading text read; no visible outline required | Outline suppressed on interactive elements too |
Integration context
Permalink to "Integration context"The deletion case is hard enough to have its own page: preserving focus when rows are deleted. Returning focus from a dialog to the element that opened it — or to a substitute when that element is gone — is covered in restoring focus after closing complex modals.
For route changes in React Router or Next.js, the same principle applies at page scale: focus the new page’s h1 (with tabIndex={-1}) after navigation, and announce the page title. The Angular and Vue equivalents are in managing route change focus in Angular and Vue.
Gotchas
Permalink to "Gotchas"Focusing inside setState callbacks. Class-component setState callbacks run after commit and are safe; function-component state setters have no callback, so the focus call must move to an effect.
CSS.escape on ids. Record ids with characters like : or . break attribute selectors. Escape them, as the hook does.
Suspense boundaries. A Suspense fallback replacing a subtree destroys focused nodes inside it. Keep focus targets outside suspending boundaries, or restore after the boundary resolves — see React Suspense loading announcements.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Why does focus jump to the top of the page after my React list updates?
Because the focused element was unmounted — usually because its key changed, often from index keys or regenerated ids. Use stable record ids as keys, and restore focus by identity in a layout effect if a node must be replaced.
Should I use autoFocus to manage focus in React?
Only for initial focus when a component first mounts, such as the first field in a dialog. It does not run on re-renders, so it cannot restore focus after data changes.
When should focus be moved programmatically in a data app?
When the element the user was on disappears or becomes meaningless — deleted rows, closed dialogs, replaced result pages, route changes. Not when announcing results; use a live region for that.
Is useLayoutEffect or useEffect better for restoring focus?
useLayoutEffect, because it runs before the browser paints, so focus is back before the user or screen reader notices it was lost. useEffect works but can briefly expose the lost state.
Related
Permalink to "Related"- Preserving focus when rows are deleted — the hardest restoration case
- Route change focus in Angular and Vue — the same problem in other frameworks
- Restoring focus after closing modals — focus return from dialogs