Live Regions in React Without Lost Announcements

Permalink to "Live Regions in React Without Lost Announcements"

A live region in React is a DOM element with aria-live (or role="status") whose text React updates, so that screen readers speak the change without moving focus. React makes it easy to get the attribute right and the timing wrong: a region rendered conditionally, a key that remounts it, or a message identical to the last one all produce silence, and nothing in React’s output looks broken.

This page explains the four ways React loses announcements and builds a small announcer that avoids them. It belongs to ARIA live regions for dynamic data, and the underlying rule — the region must exist before its content changes — is covered in creating live regions before content changes.

Spec reference

Permalink to "Spec reference"

ARIA defines aria-live="polite" | "assertive" | "off" and the roles status (implicitly polite, atomic) and alert (implicitly assertive). Browsers fire accessibility events when the text content of a live region changes. Screen readers then decide whether and when to speak.

Two consequences follow. First, a region inserted into the DOM already containing text usually produces no event — there was no change, only an insertion — and most screen readers say nothing. Second, setting a region’s text to the value it already has produces no mutation at all, so the second “Saved.” in a row is silent.

SC 4.1.3 Status Messages (Level AA) requires that status messages can be programmatically determined through role or properties so they can be presented without receiving focus. A region that is present but silent technically has the role and fails the intent.

Four ways React loses an announcement Comparison of four common React live region mistakes against the fix for each. Four ways React loses an announcement✗ Silent patterns{message && <div role="status">…</div>} —mounted with textkey={message} on the region — remountedevery timeSame text twice — no DOM mutation, no eventA region per component — several regionscompete✓ FixesRender the region always; change only its textNo key; a stable element at the app rootClear, then set on the next frameOne announcer, shared through context
Every failure on the left renders valid ARIA — the bug is in when and how the DOM changes.

When to use an announcer hook — and when not to

Permalink to "When to use an announcer hook — and when not to"

Use a shared announcer for messages that are not tied to a visible element: “Sorted by amount”, “12 results”, “Saved”, “3 rows deleted”. These are the status messages data interfaces produce constantly.

Do not use it for state that the focused element already exposes. A checkbox’s “checked”, a button’s aria-expanded, a tab’s “selected” — all are announced by the screen reader because focus is on the element. Echoing them through a live region makes the reader say them twice.

Also do not route errors that belong to a form field through the announcer alone. A field error should be attached with aria-describedby (and aria-invalid) so it is available whenever the field has focus; an announcement is a transient supplement.

The misapplication to name is a <Toast> component that renders its own role="alert" when it mounts. The toast appears with its text already in place, and many screen readers never speak it.

Annotated code example

Permalink to "Annotated code example"
// Announcer.jsx — one region per app, mounted with the root
import { createContext, useCallback, useContext, useRef, useState } from 'react';

const AnnounceContext = createContext(() => {});

export function AnnouncerProvider({ children }) {
  const [polite, setPolite] = useState('');
  const [assertive, setAssertive] = useState('');
  const frame = useRef(0);

  const announce = useCallback((message, priority = 'polite') => {
    const set = priority === 'assertive' ? setAssertive : setPolite;
    set('');                                         // 1. clear, so repeats still mutate
    cancelAnimationFrame(frame.current);
    frame.current = requestAnimationFrame(() => set(message));  // 2. set next frame
  }, []);

  return (
    <AnnounceContext.Provider value={announce}>
      {children}
      {/* SC 4.1.3: present from the first render, never conditional, never keyed */}
      <div role="status" className="visually-hidden">{polite}</div>
      <div role="alert" className="visually-hidden">{assertive}</div>
    </AnnounceContext.Provider>
  );
}

export const useAnnounce = () => useContext(AnnounceContext);
// Usage inside a data table
function InvoiceTable() {
  const announce = useAnnounce();
  const [sort, setSort] = useState(null);

  useEffect(() => {
    if (!sort) return;
    // after React has committed the new row order
    announce(`Sorted by ${sort.label}, ${sort.dir}.`);
  }, [sort, announce]);
  // …
}

The clear-then-set sequence matters for repeated messages. “Saved.” followed by another “Saved.” would otherwise be the same text and produce no mutation. Clearing forces two mutations; waiting a frame before the second lets the browser flush the first, so the screen reader sees a real change.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event Expected announcement AT-specific deviations
First announce('Sorted…') after page load Spoken politely VoiceOver may drop messages fired within the first second of page load
Same message twice Spoken twice Without the clear step: silent the second time
Two messages within 100 ms Usually only the second NVDA speaks both if the first has started; JAWS often only the last
announce(…, 'assertive') Interrupts current speech TalkBack may queue rather than interrupt
Region rendered conditionally Nothing All readers
Clear, then set on the next frame Timeline of an announcement through the hook: message requested, region cleared, animation frame, message set, accessibility event, speech. Clear, then set on the next frameannounce("Saved.")called from an effectClearregion text becomes emptyNext framebrowser has flushed the clearSetregion text is "Saved."Speechscreen reader speaks politelyone announcement, about 16 ms end to end
Two mutations a frame apart — that is what makes identical messages audible.

Integration context

Permalink to "Integration context"

Place the provider at the application root, outside route boundaries, so the regions survive navigation. In Next.js App Router, that means the root layout.js; in React Router, above the <RouterProvider>. If a route change unmounts the regions, the first announcement on the new route is lost.

For high-frequency sources such as a WebSocket feed, put a throttle in front of announce — see WebSocket updates in React with accessible announcements. The choice between the status and alert regions follows role status, alert and log compared.

Where the announcer lives in a React tree Layers of a React application showing the announcer provider at the root above the router, the route components, and the data components that call announce. Where the announcer lives in a React treeApp rootAnnouncerProvider renders the status and alert regions onceRouterroute changes swap everything below, never the regionsRoute componentspages and layoutsData componentstables, filters and forms call useAnnounce()
Regions above the router survive navigation; components anywhere below can announce through context.

Gotchas

Permalink to "Gotchas"

StrictMode double effects. In development, React runs effects twice on mount. An effect that announces on mount will announce twice in development only. Guard mount-time announcements with a ref, or better, announce only in response to user actions.

Portals. Rendering the region through createPortal into document.body is fine as long as the portal target is stable. Portals into containers that mount and unmount recreate the region.

visually-hidden versus display: none. A region hidden with display: none or the hidden attribute is not in the accessibility tree and is never announced. Use a clip-based visually-hidden class.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Why does my React live region not announce anything?

The most common cause is conditional rendering: the region is mounted at the same moment as its text, so there is no change for the browser to report. Render the region from the first render and change only its text.

How do I make React announce the same message twice?

Clear the region’s text, wait one animation frame, then set the message. The two mutations give the browser a real change to report even when the new text equals the old.

Should each component have its own live region?

No. Several regions compete and some readers only speak the last. Provide one polite and one assertive region at the app root and let components announce through a shared context.

Does React StrictMode affect announcements?

In development only, effects run twice on mount, so a mount-time announcement is spoken twice. Production builds are unaffected, but avoid announcing on mount — announce in response to user actions.

Permalink to "Related"

← Back to ARIA Live Regions for Dynamic Data