---
title: Optimistic UI — the announcement contract
description: What an optimistic update owes a screen-reader user, and how to build one that keeps the promise — the state machine, the four exits, and the four things a test has to prove
visibility: guest
---

# Optimistic UI — the announcement contract

An optimistic update shows the result of an action before the server has agreed to it. For a sighted user that is a latency win and nothing else. For a screen-reader user it is a **promise that can be withdrawn** — and a withdrawn promise is easy to make incomprehensible.

There is no WAI-ARIA APG pattern for this. That is why the rules below are written down rather than left to judgment: when you build an optimistic interaction on top of WireKit, this page is the specification you are building against.

**Scope: mutations only.** Changing something and showing it right away. Sorting, filtering, pagination and form submission are *query* round trips — nobody can show a result nobody knows yet — and are deliberately not covered. If you need those, the honest pattern is to acknowledge the *intent* (flip the sort arrow, mark the filter active) and mark the content as loading; that is a different state machine.

## The state machine

Four states and four exits, and the fourth is the one implementations get wrong:

```text
idle ──apply()──▶ optimistic ──onSuccess──▶ confirmed ──▶ idle
                       │
                       ├──onFailure/onError──▶ rolled-back ──▶ idle   (failure: 'undo', the default)
                       ├──onFailure/onError──▶ rejected ────▶ idle   (failure: 'keep' — the value stays)
                       └──onCancel──────────▶ idle          (NOT rolled-back)
```

Which of the two failure exits a component takes is `failure`, and it is decided per component rather than per request — see rule 7.

**A cancel is not a failure.** An aborted request refused nothing, so it restores the value **silently** and returns to `idle`. Announcing a rollback for it tells the user their action was rejected when it was never judged. This matters because the older Livewire channel fired for an abort and a rejection alike — the wrong behavior is the one you get by default.

## 1. One announcement on the success path, a second only on deviation

The flip announces **once**, hedged so the provisional state is audible as provisional. Confirmation is **silent**: what was announced is what happened.

Only *deviation* speaks a second time, and that is what gives a rollback its meaning — hearing a second announcement is itself the signal. Announce twice on the happy path and the rollback becomes indistinguishable from a confirmation.

**The hedge belongs in the verb, not in a concatenation.** "Liked, saving" works because *saving* is progressive: it says the thing is still in flight. "Pending — Liked" does not — it names two states where the reader needs one status, and a screen reader reads it as a list.

Every announcement is a translation key. A literal is read aloud in English to everybody.

## 2. Yield to the field's own error region

Where the component already renders an **active** error live region, the optimistic layer stays silent **on failure**. The field's message is the actionable one: "Email is required" tells the user what to do, "could not save" does not, and WCAG 3.3.1 wants the specific error identified.

Two details decide whether this works:

- **Active means carrying a message, not merely present in the DOM.** An empty error region is not a speaker. A presence check would silence the optimistic layer even when nothing else is talking — which is the common case, since form controls render that region by default.
- **The arbitration is about the failure, not the promise.** At the flip there is no error message to pre-empt, and the hedge is the one thing that makes the state audible as provisional. Silencing it there is the mistake that reads as caution.

## 3. Display and voice are separate channels

A toast is a reasonable failure surface, and **it is not an exemption from rule 2**: a toast announces through a live region too, so a failure toast on a form field pre-empts exactly the message rule 2 protects.

- **Display:** the toast appears, always.
- **Voice:** it stays silent wherever an active error region exists.

## 4. Focus never moves on a rollback

A rollback arrives on the server's schedule, not the user's. Moving focus then costs them their place for a reason they could not predict, and gains nothing an announcement does not already provide.

This holds even when the rolled-back control is no longer the right place to be. If that becomes a real problem, the answer is an affordance the user can act on, not a jump they cannot foresee.

## 5. Reduced motion is about motion, not about waiting

`prefers-reduced-motion` says "do not move things unnecessarily". It does not say "make me wait for the server". So the optimistic layer **never asks about it**: the state flips as always, and only the *transition* is skipped.

Skipping the optimism under reduced motion makes the application slower for exactly the users who asked for less movement.

## 6. The pending state has to be visible and legible

The provisional state is a state, so it carries `aria-busy` while in flight. Its visual treatment clears both contrast floors — 1.4.3 for the text, 1.4.11 for any non-text indicator. A pending style that only dims fails 1.4.3 at the moment the user most wants to read it.

## 7. An undo may not destroy what you wrote

For a toggle, a rating or a select, an undo restores the previous choice and
costs nothing. For a free-text field the previous value is the server's and the
new one is your work — so an undo would delete what you just typed because a
save failed. No editor behaves that way.

So a text field takes a different exit: **`failure: 'keep'`**. The value stays,
the state becomes `rejected` rather than `rolled-back`, and the announcement
carries the reassurance as well as the failure — because the most urgent question
a refusal raises is *is my text gone?*, and "not saved" alone leaves it
unanswered.

```blade
<div x-data="wirekitOptimistic({
    value: 'draft',
    action: 'saveDraft',
    failure: 'keep',
    messages: { pending: 'Saving', kept: 'Could not save. Your entry is still here.' },
})">
```

**The field is not marked invalid**, and that is deliberate. `aria-invalid` claims
the *content* is wrong; a save that failed on the network says nothing about the
content. Borrowing the attribute would tell a screen-reader user they made a
mistake when they did not.

`'keep'` does not by itself make a **mixed** control safe. Where a component has
both a typed field and discrete controls, putting the discrete half on `'undo'`
is not enough: a value typed while the discrete half's request is in flight would
be overwritten by that request's rollback. Such a component has to answer which
half owns the value before either exit fits.

## What a test has to prove

Assert the **rendered accessibility tree**, not the markup:

1. the accessible name and state change on the flip,
2. after a rollback they match the server's value again,
3. exactly **one** announcement leaves on the success path and **none** on a cancel,
4. focus is unchanged across all four exits.

**Count announcements; do not read the current one.** "Announced once" and "announced nothing further" are claims about the number of writes, and the final text cannot tell them apart — a test that reads the region at the end passes against an implementation that announced three times. A `MutationObserver` over the live region is the cheapest honest recorder.

Two more things worth knowing before you write that test:

- **A Livewire validation rejection is a successful response** carrying an error bag, so it confirms rather than fails. A rollback needs a genuine failure.
- **A failed request never re-renders**, so waiting for server-rendered text can never see it. Wait on client state instead.

## Using it

Load `wirekit-optimistic.js` alongside whichever bundle you already use. It is a separate file on purpose: loading it is how you accept the contract above, so applications that do not use it pay nothing for it.

Components that support it document it on their own page — see [Toggle](/components/toggle) for the first one. To build your own, mount the factory and give it the Livewire method to call:

```blade
<div x-data="wirekitOptimistic({ value: false, action: 'toggleLike', mode: 'reject' })">
    <button type="button" x-bind:aria-pressed="value" x-bind:aria-busy="isPending" x-on:click="toggle()">
        <span x-text="value ? 'Liked' : 'Like'">Like</span>
    </button>

    {{-- Rendered unconditionally and starting empty. See below. --}}
    <div class="sr-only" aria-live="assertive" aria-atomic="true" x-text="announcement"></div>
</div>
```

Three things in that snippet are contract rather than style:

1. **The live region is rendered unconditionally and starts empty.** A region that arrives together with its text is a new node, not a changed region, and assistive technology says nothing at all. This is the single most common way an optimistic implementation ends up silent while looking correct.
2. **The directives call methods.** Under Alpine's CSP build a directive holds one expression and may not name a global, so `apply(true); $wire.save()` is refused — and it does not fail loudly: the element ends up with an empty scope and every directive on it silently does nothing.
3. **`mode: 'reject'`** for a toggle. With a queue, a twice-flipped toggle resolves by the order the responses arrive in — network timing, which is both wrong and impossible to test.

`x-ref="control"` on a native input is worth adding: a checkbox flips itself before any handler runs, and a binding only writes when the bound value changes, so a refused flip would leave the control showing a state nothing agreed to.

If the Livewire method named in `action` does not exist — a typo, or Livewire not loaded yet — the control refuses the flip rather than showing a change nothing was asked to make, and says so in the console when the factory is given `debug: true`. WireKit's own components pass `config('app.debug')` there, so the warning appears where you develop and nowhere else; hand-mounting the factory, you choose. A control that does nothing and stays quiet is the one failure this layer is built to avoid.
