Loading Announcements With React Suspense
Permalink to "Loading Announcements With React Suspense"React Suspense shows a fallback — a skeleton or spinner — while a component waits for data, then swaps in the real content. Visually this is smooth. For screen reader users, the fallback appears silently, the content appears silently, and if focus was inside the boundary when it suspended, focus is destroyed along with the old content. A user who pressed “Next page” hears nothing, and their next Tab starts from the top of the document.
This page wraps Suspense boundaries with the announcements and focus handling they lack, and uses transitions to avoid the fallback entirely for refetches. It belongs to progressive loading & skeleton states.
Spec reference
Permalink to "Spec reference"React behaviours that matter (React 18/19):
- When a component inside
<Suspense>suspends during its first render, React shows the nearest boundary’sfallback. - When already-visible content suspends again outside a transition, React replaces it with the fallback, unmounting the content — and any focused element inside it.
- Updates wrapped in
startTransition(or driven byuseDeferredValue) that suspend keep the previous UI on screen instead of showing the fallback;useTransitionexposes anisPendingflag.
ARIA side: a live region must exist before its content changes, so the status region belongs outside every boundary. aria-busy on the region being replaced is a hint for some readers.
Criteria: SC 4.1.3 Status Messages (loading and loaded), SC 2.4.3 Focus Order (focus not lost when a boundary suspends), SC 2.2.1 when loading is long enough to interact with session timers.
When to show the fallback — and when to keep old content
Permalink to "When to show the fallback — and when to keep old content"Show the Suspense fallback on first load, when there is nothing else to show. Pair it with a “Loading invoices…” message only if loading takes longer than about a second.
Keep old content for refetches — sort, filter, page change, refresh. Use startTransition so the current table stays readable and focused while the new data loads, and mark it busy. Replacing a table the user is reading with a skeleton, only to replace the skeleton a moment later, is disorienting for everyone and destructive for keyboard users.
The misapplication to name is putting the page’s only live region inside the Suspense boundary, next to the table. When the boundary suspends, the region is unmounted with the table; when it resolves, the region is mounted with its message already inside — and nothing is announced.
Annotated code example
Permalink to "Annotated code example"import { Suspense, useDeferredValue, useEffect, useRef, useTransition } from 'react';
import { useAnnounce } from './Announcer'; // region lives at the app root
export function InvoicesPage() {
const [query, setQuery] = useState({ page: 1, sort: 'due' });
const [isPending, startTransition] = useTransition();
const announce = useAnnounce();
const slowTimer = useRef();
// SC 4.1.3: say "Loading…" only if it is actually slow
useEffect(() => {
clearTimeout(slowTimer.current);
if (isPending) slowTimer.current = setTimeout(() => announce('Loading invoices…'), 1000);
return () => clearTimeout(slowTimer.current);
}, [isPending, announce]);
const go = (next) => startTransition(() => setQuery((q) => ({ ...q, ...next })));
return (
<section aria-labelledby="inv-h">
<h2 id="inv-h" tabIndex={-1}>Invoices</h2>
<Toolbar onSort={(sort) => go({ sort, page: 1 })} />
{/* SC 2.4.3: the old table stays mounted during refetches */}
<div aria-busy={isPending || undefined} className={isPending ? 'is-stale' : undefined}>
<Suspense fallback={<TableSkeleton label="Loading invoices" />}>
<InvoiceTable query={query} onRendered={(info) => announce(
`Page ${info.page} of ${info.pages}, invoices ${info.from} to ${info.to}.`)} />
</Suspense>
</div>
<Pagination page={query.page} onChange={(page) => go({ page })} />
</section>
);
}
function InvoiceTable({ query, onRendered }) {
const data = use(fetchInvoices(query)); // suspends until ready
// announce after commit, from the resolved data
useEffect(() => { onRendered(data.info); }, [data]);
return <table>…</table>;
}
// Skeleton fallback: visible shape, one accessible label, no fake rows in the tree
function TableSkeleton({ label }) {
return (
<div className="skeleton" role="status"> {/* polite, atomic */}
<span className="visually-hidden">{label}</span>
<div aria-hidden="true">{/* grey bars */}</div>
</div>
);
}
The skeleton’s own role="status" is a pragmatic exception to “regions must exist before content changes”: role="status" mounted with text is announced by some readers and not others. That is acceptable for the first-load case, where the more important announcement is the loaded result from the root region.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected behaviour | Announcement |
|---|---|---|
| First load (slow) | Skeleton visible | “Loading invoices” (from skeleton, best-effort) then result |
| Sort (fast) | Old table stays, dimmed; new rows appear | “Page 1 of 40, invoices 1 to 50.” |
| Page change (slow) | Old table stays, aria-busy |
After 1 s: “Loading invoices…”; then the range |
| Focus on “Next page” during load | Focus stays on the button | — |
| Boundary suspends without transition | Fallback replaces table | Focus lost — avoid |
Integration context
Permalink to "Integration context"The root announcer comes from live regions in React without lost announcements. The aria-busy and “stale” styling follow using aria-busy during progressive table loads, and the skeleton-versus-spinner choice is discussed in accessible skeleton screens versus spinners.
If focus must move after loading — to the table caption after a page change, say — do it in an effect in the resolved component, as in focus management in React with refs and effects.
Gotchas
Permalink to "Gotchas"Nested boundaries. Many small boundaries produce many fallbacks and, if each announces, many messages. Announce from the outermost meaningful unit only.
Streaming SSR. With streamed server rendering, boundaries resolve during page load; do not announce those — users are already hearing the page load.
use() and effects in StrictMode. In development, effects run twice; guard announcements so they fire once per data change.
Design system notes
Permalink to "Design system notes"A data-loading wrapper component can encode these rules: it renders the busy wrapper, the Suspense boundary with a standard skeleton, the delayed loading message, and an onLoaded announcement hook — and requires callers to trigger refetches through a transition-aware refetch() function rather than raw state setters.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Does React Suspense announce loading to screen readers?
No. The fallback and the resolved content replace each other silently. Add a status region outside the boundary and announce loading, if it is slow, and the loaded result yourself.
How do I stop Suspense replacing my table with a skeleton on every refetch?
Wrap the state update that triggers the refetch in startTransition, or derive the query with useDeferredValue. React then keeps the old content visible until the new content is ready.
Why is focus lost when my component suspends?
Because showing the fallback unmounts the content, including the focused element. Keep controls and focus targets outside the boundary, use transitions for refetches, and restore focus in an effect when needed.
Should skeleton screens be announced?
Give the skeleton one text label such as “Loading invoices” and hide its placeholder shapes from assistive technology. Announce the loaded result from a region that stays mounted.
Related
Permalink to "Related"- aria-busy during progressive loads — the non-React pattern
- Live regions in React — the announcer used here
- Focus management in React — restoring focus after a boundary