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 in useEffect or useLayoutEffect, or in an event handler after state is set and committed.
  • React preserves a DOM node across renders only if its component type and key are unchanged. A changed key destroys the node — and its focus.
  • autoFocus in React calls focus() 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).

How React loses focus Flow of a common React focus loss: a user focuses a row, data refreshes, keys change, React unmounts the row, and focus falls to body. How React loses focusFocus on row40user presses arow actionData refreshnew array from theserverKeys changeindex keys, or newidsRow unmountedfocused nodedestroyedFocus on bodynext Tab startsfrom the top
No error is thrown and nothing looks wrong — the user is simply back at the top of the page.

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
React tools for focus and when each runs Matrix of React focus mechanisms — autoFocus, useEffect, useLayoutEffect, event handlers and stable keys — with when each runs and what it is good for. React tools for focus and when each runsMechanismRuns whenGood forautoFocusFirst mount onlyInitial focus in a dialoguseEffectAfter paintFocus after a user-driven changeuseLayoutEffectBefore paintRestoring lost focus invisiblyStable keyEvery renderKeeping the node alive at allEvent handler + flushSyncSynchronouslyRare: focus before the next event
Restoration belongs in a layout effect keyed on the data; initial focus can use autoFocus.

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.

Keys and focus survival Comparison of array-index keys and stable record-id keys in a React list, showing what happens to the focused element on refresh and sort. Keys and focus survival✗ key={index}Sorting reuses nodes for different recordsFocus stays on a node now showing anotherrecordInserting at the top shifts every keyScreen reader re-reads the wrong row✓ key={record.id}Nodes follow their records through sortsFocus stays on the same recordInserts do not disturb other rowsRestoration by id is rarely needed
Keys are a focus-management decision as much as a performance one.

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.

Permalink to "Related"

← Back to Focus Management in Single-Page Apps