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’s fallback.
  • 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 by useDeferredValue) that suspend keep the previous UI on screen instead of showing the fallback; useTransition exposes an isPending flag.

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.

A page change with and without a transition Timeline comparing a page change inside a transition, where the old table stays visible and focus is kept, with one outside a transition, where the fallback replaces the table. A page change with and without a transitionNext page pressedsetPage(p + 1)Without transitionfallback replaces table; focus lostWith transitionold table stays; isPending trueData readynew rows commitAnnounce"Page 3 of 40, invoices 101 to150."one page change in a Suspense-based table
With startTransition the old rows stay until the new ones are ready — no fallback, no lost focus.

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
Refetch outside and inside a transition Comparison of a Suspense refetch triggered by a plain state update against one wrapped in startTransition. Refetch outside and inside a transition✗ Plain setStateFallback replaces the visible tableFocused elements inside are unmountedReading position lost✓ startTransitionOld table stays; marked busy and dimmedFocus and reading position keptisPending drives a delayed loading message
One wrapper changes the refetch from destructive to invisible — for sighted and screen reader users alike.

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.

Where each piece lives relative to the boundary Layers showing the announcer region at the app root, the page with its heading and controls outside the boundary, the Suspense boundary, and the table inside it. Where each piece lives relative to the boundaryApp rootthe announcer's status and alert regionsPage shellheading, toolbar, pagination: focus targets that never unmountBusy wrapperaria-busy while a transition is pendingSuspense boundaryfallback on first load onlyTableannounces its loaded range after commit
Anything that must survive a suspension — regions, focus targets, controls — lives outside the boundary.

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.

Permalink to "Related"

← Back to Progressive Loading & Skeleton States