livewire/livewire: No more phantom blurs: `wire:model.live.blur` skips requests when nothing changed
Contributed by people-sea
Part of the livewire v4.4.7 release
Pull request #10284 fixes a quiet source of wasted network traffic in Livewire's data binding: until now, an input bound with wire:model.live.blur fired a request every time it lost focus, even if nothing had changed since the last sync. In the 4.x branch, the blur handler now checks whether the component is dirty before sending, so an unchanged blur becomes a true no-op on the network. If you're upgrading from v3.8.10, this lands as a performance refinement you get automatically — with one behavioral edge case to be aware of, covered in the upgrade notes below.
What changed in the blur handler
For bindings that trigger the network on blur — wire:model.blur and wire:model.live.blur — the wire:model directive previously bound the @blur event straight to update(), unconditionally dispatching a request. The merged diff replaces that with a guarded version:
bindings['@blur'] = () => {
queueMicrotask(() => {
let target = expression.startsWith('$parent') ? component.parent : component
// Skip the request if nothing has changed since the last server sync...
if (target && ! checkDirty(target)) return
update()
})
}
The new ingredient is checkDirty(), imported from the wire-dirty module — the same state-diffing helper that powers wire:dirty's tracking. It compares the component's current client-side state against the last snapshot received from the server. If the two match, the blur is considered clean and update() never runs.
Worth noting: the pull request description frames this as "only a failing test", but the merged diff contains both halves — the guard in the directive and a browser test that locks the new behavior in.
The redundant request, before and after
Consider the textbook case:
<input type="text" wire:model.live.blur="title">
<span>{{ $title }}</span>
On v3.8.10, the flow looks like this: you type "hello", click away, and request #1 syncs title to the server — correct. Then you click back into the field, hesitate, and click away again without typing. Livewire still fires request #2, carrying byte-for-byte identical data.
The new browser test in src/Features/SupportDataBinding/BrowserTest.php codifies exactly this scenario. It counts outgoing requests using Livewire's intercept() hook, types "hello", blurs, and asserts a request count of 1 — then refocuses the input, blurs again, and asserts the count is still 1. That test is now the executable contract for the feature.
Why it matters
Every Livewire request is a full round trip: the component re-renders on the server, the response is diffed and morphed into the DOM, and lifecycle hooks fire along the way. A redundant request pays that entire cost to change nothing. In practice that means wasted server CPU and bandwidth, needless work for side effects triggered in hooks, and — worst case — a flicker or race condition as a no-op response morphs over a DOM the user is actively editing.
Forms that lean on .blur binding are hit hardest. Tabbing through a dozen .blur-bound fields to reach a submit button can fire a burst of requests, most of which sync nothing. The fix also sharpens the semantics: .blur now means "sync my changes when I leave the field," not "ping the server every time I leave the field."
Subtleties baked into the implementation
Three details in the diff deserve a callout:
- Microtask ordering. Some apps stack modifiers — an ephemeral
.blursync combined with.live.blurnetwork-on-blur. The guard defers its dirty check withqueueMicrotask(), so any ephemeral sync triggered by the same blur event lands first and is correctly accounted for, rather than being swallowed or double-sent. $parentawareness. If the model expression targets the parent component (wire:model.live.blur="$parent.title"), the dirty check runs against the parent component, not the child.- Component-level granularity.
checkDirty()inspects the component's whole state, not just the one input. If a sibling field on the same component has pending changes, the blur still sends — the right call, since the outgoing request carries those updates along. By the same token, typing and then reverting back to the synced value counts as clean.
Upgrade impact coming from v3.8.10
For the vast majority of apps, this is a silent win: no API changes, no template changes, and noticeably less request traffic in blur-heavy forms.
The one edge case: if you relied on blur always hitting the server — for example, to save a draft, bump a timestamp, or run per-blur validation — those no-op triggers now stop firing. The remedy is to make the side effect explicit and independent of model syncing:
<input
type="text"
wire:model.live.blur="title"
@blur="$wire.saveDraft()"
>
The Alpine @blur listener fires on every blur regardless of dirtiness, while the .live.blur binding now only syncs when there is something to sync. The two concerns are cleanly separated.
Takeaways
wire:model.live.blur(and other blur-triggered bindings) no longer send a request when the component's state is unchanged since the last server sync.- The guard reuses
checkDirty()from thewire:dirtyfeature — component-level,$parent-aware, and value-based. - Combined modifier setups (ephemeral
.blurplus.live.blur) are preserved thanks to microtask ordering. - Upgrading from v3.8.10 requires no template changes; if you depended on every blur reaching the server, trigger those side effects explicitly with
@blur="$wire.save()". - A new browser test that counts intercepted requests locks the guarantee in place.