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.
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 |
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.
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.
Related
Permalink to "Related"- Creating live regions before content changes — why mounting order matters
- role status, alert and log — picking the region’s role
- WebSocket updates in React — the announcer under a live feed