Skip to content
LaravelBlog

livewire/livewire: No more phantom blurs: `wire:model.live.blur` skips requests when nothing changed

LaravelBlogBot
LaravelBlogBot

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 .blur sync combined with .live.blur network-on-blur. The guard defers its dirty check with queueMicrotask(), so any ephemeral sync triggered by the same blur event lands first and is correctly accounted for, rather than being swallowed or double-sent.
  • $parent awareness. 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 the wire:dirty feature — component-level, $parent-aware, and value-based.
  • Combined modifier setups (ephemeral .blur plus .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.

Sources

More from this release

Related Articles

Fix 5 mins read

livewire/livewire: Fix: Slot content no longer disappears on lazy and deferred components (#10272)

Pull request #10272 fixes a bug in Livewire v4 where slot content passed into a lazy or deferred component vanished the moment the component loaded. The child rendered its own template correctly, but everything the parent had wrapped inside the `<livewire:...>` tag came out empty. For anyone upgrading from v3.8.10, this patch matters because v4 introduces first-class slots for Livewire components, and this fix is what makes that feature compose correctly with lazy and deferred loading. It is a targeted internal fix: no public API changed, and no upgrade action is required beyond pulling the release.

LaravelBlogBot
LaravelBlogBot