Angular Signals and Live Announcements
Permalink to "Angular Signals and Live Announcements"Angular’s signals make data state explicit: filters, sort, page and results become signals, and derived values — like “42 invoices, sorted by Amount” — become computed signals. That is a natural fit for announcements: the message is a pure function of state. The care needed is in when to announce. An effect that announces every time the computed message changes will also fire on initial render, after hydration, and on programmatic state restores — all moments when users did not ask for anything.
This guide belongs to Angular data components.
Spec reference
Permalink to "Spec reference"- SC 4.1.3 Status Messages (AA) — status messages for results of user actions.
- Signals API —
signal(),computed(),effect();untracked()to read without subscribing. - LiveAnnouncer (CDK) — the output channel.
- Timing principle: announce changes users caused; stay silent for initial and restored state.
When to use — and when not to
Permalink to "When to use — and when not to"Use computed messages for any announcement that summarises state: result counts, sort order, page position, selection counts. Use effects with an intent gate to announce them.
Do not announce from effects without a gate; you will announce on load. Do not put the announcement text in the template’s visible DOM and also announce it, unless that visible text is the status region itself.
The misapplication: effect(() => announcer.announce(message())) with no gate, which speaks “240 invoices” every time the page loads and every time a websocket refresh updates data.
Annotated code example
Permalink to "Annotated code example"@Component({ selector: 'app-invoices', templateUrl: './invoices.html' })
export class InvoicesComponent {
private announcer = inject(LiveAnnouncer);
filter = signal('');
sort = signal<{ field: string; dir: 'asc' | 'desc' } | null>(null);
rows = input.required<Invoice[]>();
visible = computed(() => sortRows(filterRows(this.rows(), this.filter()), this.sort()));
message = computed(() => { // pure text from state
const s = this.sort();
return `${this.visible().length} invoices` + (s ? `, sorted by ${label(s.field)} ${s.dir === 'asc' ? 'ascending' : 'descending'}` : '');
});
private userChanged = false; // intent gate
private timer?: ReturnType<typeof setTimeout>;
constructor() {
effect(() => {
const text = this.message(); // track message changes
if (!this.userChanged) return; // silent on load and restores (SC 4.1.3 intent)
clearTimeout(this.timer);
this.timer = setTimeout(() => this.announcer.announce(text, 'polite'), 400); // debounce
});
}
onFilterInput(v: string) { this.userChanged = true; this.filter.set(v); }
onSort(field: string) { this.userChanged = true; this.sort.update(s => toggle(s, field)); }
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Signal changes | Announced |
|---|---|---|
| Page load | rows, message | Nothing |
| Typing “overdue” (6 keystrokes) | filter ×6 | “42 invoices” once |
| Sort by Amount | sort | “42 invoices, sorted by Amount ascending” |
| Background refresh (same filter) | rows | Nothing (or a stale/refresh message if important) |
| Restore from URL on Back | filter, sort | Nothing |
Integration context
Permalink to "Integration context"The announcer itself is described in Angular CDK a11y: LiveAnnouncer and FocusMonitor. Timing rules mirror debouncing status messages for bulk operations and announcing filter result counts with aria-live. Live data refresh policies are covered in real-time data stream announcements.
Gotchas
Permalink to "Gotchas"Effects run at least once. Every effect runs on creation; the gate must handle that first run.
Zoneless change detection. In zoneless Angular, effects still schedule correctly; avoid relying on zone-based timing for debouncing.
Server rendering. Effects that touch LiveAnnouncer should not run on the server; guard with isPlatformBrowser or use afterNextRender.
Common mistakes
Permalink to "Common mistakes"- Ungated effects that announce on load.
- Announcing every intermediate filter value.
- Treating background refreshes like user actions.
- Duplicating messages in template live regions.
Adoption checklist for teams
Permalink to "Adoption checklist for teams"- Model filter, sort, page and results as signals.
- Derive messages with computed signals in one place per component.
- Use a shared helper (e.g.
announceOnUserChange(message, flag)) that implements gating and debouncing. - Guard browser-only code for SSR.
- Unit test that loading does not announce and user actions do.
Version and upgrade notes
Permalink to "Version and upgrade notes"Signals stabilised across recent Angular versions (signal inputs, effect behaviour changes). When upgrading, re-check that effects still run after the changes you expect, and that untracked usage is still correct.
Worked example
Permalink to "Worked example"An Angular analytics page migrated to signals and added effect(() => announcer.announce(message())). Screen reader users started hearing “1,240 sessions” on every page load and every 30-second refresh. Adding a user-intent flag, a 400 ms debounce and a separate, rare message for background refreshes (“Data refreshed at 10:42”) restored calm. Tests now assert that loading the page produces no announcement.
A reusable helper
Permalink to "A reusable helper"export function announceOnUserChange(message: Signal<string>, announcer: LiveAnnouncer, delay = 400) {
let user = false, t: ReturnType<typeof setTimeout> | undefined;
effect(() => { const m = message(); if (!user) return; clearTimeout(t); t = setTimeout(() => announcer.announce(m), delay); });
return { markUserChange: () => (user = true) };
}
Components call markUserChange() in their event handlers. The helper keeps the gate and debounce identical across components, which keeps announcement timing consistent for users.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Provide a signals-based announcement helper in the design system’s Angular package, with the gate, debounce and SSR guard built in, so components only provide the computed message.
FAQ
Permalink to "FAQ"Can I use computed signals for the status region text directly?
Yes, if the status region is in the template and always rendered; binding its text to a computed message works. Still gate changes on load if needed.
How long should the debounce be?
300–500 ms for typing; shorter for discrete actions like sorting.
How do I tell user changes from programmatic ones?
Set a flag in event handlers, or pass a source with each state update in a store.
Should errors be debounced?
No. Announce errors immediately and assertively.
Do signals change anything about accessibility itself?
No — they make state and derived text easier to manage. The timing and politeness rules stay the same.
How do I test announcements with signals?
Spy on LiveAnnouncer.announce, trigger user handlers, advance timers and assert calls; assert no call after initial render.
What about RxJS-based components?
The same gate and debounce apply; use debounceTime and filter on a user-action stream.
Should computed messages include the filter text?
Including it helps when users may have forgotten what they typed: “42 invoices match ‘overdue’”. Keep it short.
What about zoneless applications?
The pattern is the same. Effects schedule independently of zone.js, and setTimeout-based debouncing works without zones.
Related
Permalink to "Related"- CDK LiveAnnouncer and FocusMonitor — the announcer
- Announcing filter result counts — message content
- Vue announcer composable — the Vue equivalent