Frontend
Accessible Async React Interfaces: Status, Focus, and Recovery
By Vivek Jalondhara · 5 min read
Published
Make loading, completion, and retry behavior understandable without disrupting keyboard focus or overwhelming screen-reader announcements.
A spinner answers very little on its own. It may be visible, but it does not explain which operation is running, whether existing results remain usable, or where focus will land when the operation finishes. Async accessibility begins with those decisions.
This educational Sheet Parser scenario refreshes a vendor’s extracted prices while someone reviews the current rows. The proposed interface keeps the review context available and announces meaningful changes. It is a design walkthrough, not an accessibility audit or a claim about the deployed application.
Describe what changed and what remains usable
Distinguish initial loading from refreshing existing data. An initial request may have no rows to display, while a refresh can retain the current rows with a clear updating message. Avoid replacing the entire review panel with a loading component whenever a request starts.
This distinction also improves error recovery. “Could not load prices” calls for a first-load retry. “Could not refresh prices” explains that visible rows may be older. Keep error text near the affected content and retain a usable retry control after the request settles.
Give status messages a stable home
Use a persistent status region for concise messages such as “Refreshing prices” and “18 prices available.” A status message can be exposed to assistive technology without moving focus. Keep the region mounted before its text changes, and make the text understandable without relying on its visual position.
Do not put the entire table inside a live region. Announcing every changed cell would compete with the user’s reading. Choose meaningful transitions, and avoid repeating the same completion message for background polling that has not changed anything the person needs to know.
Keep busy content and announcements separate
Place aria-busy on the content region being updated and clear it when the relevant operation settles, including failure. Assistive technology may defer changes inside a busy region. Keeping the concise status message outside that region allows the loading explanation to remain independently available.
Use one message owner for a request. If both a global notification and a local alert announce the same error, the user may hear it twice. Reserve interruptive alerts for situations that justify interrupting the current task; an ordinary refresh failure can usually use the existing status channel.
Preserve focus through ordinary updates
A completed refresh should generally leave focus where the user placed it. Avoid automatically focusing the table heading or the first result. When an explicit action removes the focused row, choose a predictable surviving target, such as the next row’s action or the review toolbar.
Keep the refresh button mounted while work is active. The example uses aria-disabled to expose temporary unavailability while keeping the control focusable. Unlike a native disabled attribute, it does not block activation, so the handler must guard it and the request owner must prevent duplicate work.
Connect validation to an actionable correction
If accepting extracted prices fails because a currency is missing, preserve the user’s edits and identify the affected row and field. An error summary can link to that input, while the input references its own explanatory message. A generic “Import failed” notification leaves the correction hidden.
Consider the stage of work before moving focus. Submitting a form with validation errors may justify focusing a summary or the first invalid field. A background refresh does not create that same expectation. Focus policy should follow the user’s action rather than a generic request-success effect.
Verify an entire interrupted workflow
Try keyboard navigation while a delayed refresh completes, retry after a failure, and refresh while the focused row disappears from the result. Confirm that visible messages match announcements and that focus remains visible. Test an empty result separately from a failed request.
Automated checks can catch missing names and invalid relationships, but cannot establish whether announcements are timely or excessive. Use supported browser and screen-reader combinations for manual checks. Also test zoom and reduced motion so a loading treatment does not obscure controls or depend on animation to communicate state.
Keep the status message outside busy results
The request owner supplies the current phase, retained result content, and refresh callback. This component preserves its button and live region across transitions; request deduplication remains the owner’s responsibility.
Illustrative example · TSX · Simplified for explanation
import { useId, type ReactNode } from "react";
type Props = {
phase: "idle" | "loading" | "ready" | "error";
count: number;
children: ReactNode;
onRefresh: () => void;
};
function PriceResults({ phase, count, children, onRefresh }: Props) {
const headingId = useId();
const pending = phase === "loading";
const message = pending ? "Refreshing prices." : phase === "error"
? "Could not refresh prices. Try again."
: phase === "ready" ? `${count} prices available.` : "";
return <>
<button type="button" aria-disabled={pending}
onClick={() => { if (!pending) onRefresh(); }}>
Refresh prices
</button>
<p role="status" aria-atomic="true">{message}</p>
<section aria-labelledby={headingId} aria-busy={pending}>
<h2 id={headingId}>Vendor prices</h2>
{children}
</section>
</>;
}- Start the requested refresh
- Announce a concise status
- Retain content and keyboard context
- Announce completion or an actionable failure
Conceptual flow for the example above.
Tradeoff to keep in mind
Retaining focus and existing results preserves context, but stale content needs clear labeling. ARIA attributes support that behavior; they do not replace a coherent state model or manual usability checks.