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:
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.
<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:
- the accessible name and state change on the flip,
- after a rollback they match the server's value again,
- exactly one announcement leaves on the success path and none on a cancel,
- 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 for the first one. To build your own, mount the factory and give it the Livewire method to call:
<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:
- 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.
- 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. 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.