WebSocket Updates in React With Accessible Announcements

Permalink to "WebSocket Updates in React With Accessible Announcements"

A WebSocket feed can deliver dozens of updates a second: price ticks, job statuses, sensor readings, presence changes. React will happily re-render a table for each one. If each update also writes to a live region, a screen reader user hears an unending stream of numbers that drowns out everything else — or, more often, hears nothing useful because each message cancels the last. The data should flow at full speed; the speech should flow at a pace a person can follow.

This page builds a React hook that separates the two: state updates immediately, announcements are buffered, summarised and flushed on an interval, with an assertive path for alerts and a pause for users. It belongs to real-time data stream announcements and applies throttling high-frequency aria-live updates to React.

Spec reference

Permalink to "Spec reference"

WebSocket delivers message events as they arrive; React batches state updates within the same task in React 18+, but separate messages arrive in separate tasks, so each can trigger a render. useSyncExternalStore is the idiomatic way to subscribe a component to an external stream without tearing.

On the ARIA side, the announcer regions are the polite role="status" and assertive role="alert" at the app root. Criteria:

  • SC 4.1.3 Status Messages — updates are announced without moving focus.
  • SC 2.2.2 Pause, Stop, Hide (Level A) — auto-updating content that starts automatically and is presented in parallel with other content must be pausable, unless it is essential. A live-updating table qualifies; the pause control covers it.
  • SC 2.2.1 Timing Adjustable — relevant if updates remove content the user is reading.
Two paths for every message Flow showing each WebSocket message applied to React state immediately, while announceable events go to a buffer that is summarised and flushed on an interval, and alerts bypass the buffer. Two paths for every messageMessagefrom the socketApplysetStateimmediatelyClassifyalert, notable,routineBuffernotable eventscountedFlushone summary every5 s
Data at network speed, speech at human speed — and alerts at once.

When to announce stream updates — and when not to

Permalink to "When to announce stream updates — and when not to"

Announce only what a user would want to hear without asking: alerts crossing thresholds, jobs failing, items assigned to them. Summarise routine changes (“12 prices updated”) at most every few seconds, and only if the user has asked for live announcements.

Do not announce every tick. A price feed at 20 updates per second is 20 messages per second; even a perfect screen reader cannot speak them, and trying makes the page unusable. The table is the source of truth; users read it when they want current values.

The misapplication to name is useEffect(() => announce(...), [latestTick]) — an effect that announces on every state change. It looks idiomatic and produces a flood.

Annotated code example

Permalink to "Annotated code example"
import { useEffect, useReducer, useRef, useState } from 'react';
import { useAnnounce } from './Announcer';

export function useLiveFeed(url, { flushMs = 5000 } = {}) {
  const [rows, dispatch] = useReducer(applyTick, new Map());   // visible state
  const [paused, setPaused] = useState(false);                // SC 2.2.2 user control
  const announce = useAnnounce();
  const buffer = useRef({ updates: 0, notable: [] });
  const hidden = useRef(document.visibilityState === 'hidden');

  useEffect(() => {
    const ws = new WebSocket(url);
    ws.onmessage = (e) => {
      const tick = JSON.parse(e.data);
      dispatch(tick);                                         // 1. data: always, immediately
      if (tick.severity === 'alert' && !paused) {            // 2. alerts: now, assertive
        announce(`${tick.symbol}: ${tick.message}`, 'assertive');
        return;
      }
      buffer.current.updates++;                               // 3. everything else: buffered
      if (tick.severity === 'notable') buffer.current.notable.push(tick);
    };
    return () => ws.close();                                  // cleanup on unmount
  }, [url, paused, announce]);

  useEffect(() => {
    const onVis = () => { hidden.current = document.visibilityState === 'hidden'; };
    document.addEventListener('visibilitychange', onVis);
    const id = setInterval(() => {
      if (paused || hidden.current) return;                   // user pause + tab hidden
      const { updates, notable } = buffer.current;
      buffer.current = { updates: 0, notable: [] };
      if (!updates) return;
      // SC 4.1.3: one polite summary per interval
      const lead = notable.slice(-2).map((t) => `${t.symbol} ${t.change}`).join(', ');
      announce(lead ? `${updates} updates. ${lead}.` : `${updates} updates.`);
    }, flushMs);
    return () => { clearInterval(id); document.removeEventListener('visibilitychange', onVis); };
  }, [paused, flushMs, announce]);

  return { rows, paused, setPaused };
}
function LivePrices() {
  const { rows, paused, setPaused } = useLiveFeed('wss://feed.example/prices');
  return (
    <section aria-labelledby="lp-h">
      <h2 id="lp-h">Live prices</h2>
      {/* SC 2.2.2: visible, keyboard-operable pause for announcements */}
      <button type="button" aria-pressed={paused} onClick={() => setPaused((p) => !p)}>
        Pause live announcements
      </button>
      <PriceTable rows={rows} />
    </section>
  );
}

The pause here pauses announcements, not data. Some products also need a “freeze table” control, so users can read a snapshot without values changing under them; that is a separate control, and it should say so.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event Expected behaviour Announcement
Stream running, routine ticks Table updates continuously Every 5 s: “34 updates. ACME up 2%, GLOBEX down 1%.”
Alert tick Table updates Immediately, assertive: “ACME: price below stop level.”
Pause pressed Announcements stop; data continues “Pause live announcements, toggle button, pressed”
Tab hidden No announcements On return: summary of the interval (see visibility guide)
Component unmounts Socket closed, timers cleared None
Messages spoken in one minute of a 20-per-second feed Bar chart comparing announcements in one minute of a feed delivering 20 updates per second: announcing every tick, a 1-second throttle, and a 5-second summary. Messages spoken in one minute of a 20-per-second feedEvery tick1200 announcements — impossible to follow1-second throttle60 announcements — still continuous speech5-second summary12 announcements
1,200 ticks a minute become 12 summaries — the table carries the rest.

Integration context

Permalink to "Integration context"

The announcer and its regions come from live regions in React without lost announcements. If other parts of the page also announce — sort results, save confirmations — route everything through an announcement queue with the feed summary at low priority, so a user’s own action is never drowned out by a tick summary.

The hidden-tab behaviour, including the resume summary, is detailed in pausing live regions when the tab is hidden.

Announcing from an effect versus from a buffer Comparison of announcing inside a useEffect keyed on the latest tick against buffering announceable events and flushing a summary on an interval. Announcing from an effect versus from a buffer✗ useEffect on latest tickOne message per renderMessages cancel each otherNo pause✓ Buffer and flushOne summary per intervalAlerts bypass the buffer, assertiveUser pause and hidden-tab pauseRenders and speech independent
The effect pattern looks idiomatic React and produces a flood; the buffer decouples speech from renders.

Gotchas

Permalink to "Gotchas"

Stale closures. The socket handler captures paused from the render that created it. Recreate the handler when paused changes (as above), or read it from a ref.

Reconnect storms. A reconnect that replays missed messages can dump hundreds of updates at once. Treat a replay as one event: “Reconnected. 240 updates while disconnected.”

Focus in changing rows. If a row the user has focused changes value, do not re-key or re-create it; update it in place so focus and reading position survive.

Design system notes

Permalink to "Design system notes"

Provide a useLiveAnnouncements(source, { classify, summarise }) hook alongside the announcer: products supply how to classify an event and how to phrase a summary; the hook owns buffering, flushing, alert bypass, pause and visibility. It is the piece every real-time product otherwise reinvents — usually without the pause.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
How should a React app announce WebSocket updates?

Apply every message to state immediately, but announce through a buffer: count routine updates and flush one polite summary every few seconds, send alerts to an assertive region at once, and let users pause announcements.

Does a live-updating table need a pause control?

Under SC 2.2.2, content that updates automatically alongside other content must be pausable unless the updates are essential. A pause for announcements, and optionally a freeze for the table, covers it.

Why not announce inside a useEffect on each update?

Because every render becomes a message. Messages arrive faster than they can be spoken and cancel each other, so users hear a stream of fragments or nothing useful.

What should happen when the socket reconnects?

Treat any replayed backlog as a single event and announce a summary such as “Reconnected, 240 updates while disconnected”, rather than announcing each replayed message.

Permalink to "Related"

← Back to Real-Time Data Stream Announcements