How Livewire v3 Actually Updates the DOM
Most developers treat Livewire as a black box: submit a request, get HTML back, page updates. The reality is more surgical. Livewire v3 uses a morphing algorithm — borrowed conceptually from morphdom — that walks the existing DOM and the incoming HTML simultaneously, patching only what changed.
The key to making this work reliably are morph markers: invisible HTML comments injected around dynamic regions.
<!-- [if BLOCK]><![endif] -->
<div wire:key="item-1">...</div>
<!-- [if ENDBLOCK]><![endif] -->
These markers give the morpher stable anchors so it can match old nodes to new ones without relying solely on element position. If you ever see unexpected flickers or lost focus states, a missing wire:key on a looped element is almost always the culprit — the morpher falls back to positional matching and replaces nodes it could have patched.
Practical Rule
Always add wire:key to every element inside @foreach. Use a domain-meaningful key, not $loop->index:
@foreach($orders as $order)
<div wire:key="order-{{ $order->id }}">
{{ $order->reference }}
</div>
@endforeach
JavaScript Lifecycle Hooks
Livewire v3 exposes a rich JS hook system via Livewire.hook(). This is the correct extension point for third-party integrations, analytics, and custom DOM work — not document.addEventListener('livewire:navigated', ...).
Available Hook Points
| Hook | When it fires |
|---|---|
| component.init | After a component's JS object is created |
| request | Before/after each network round-trip |
| commit | Around the server commit lifecycle |
| morph | Before/after DOM patching |
| element.init | When a DOM element is first processed |
A real-world use case: reinitialise a third-party chart library after every morph without leaking instances.
Livewire.hook('morph.updated', ({ el, component }) => {
if (el.dataset.chart !== undefined) {
destroyChart(el);
initChart(el);
}
});
The commit hook is particularly useful for optimistic UI patterns — you can intercept the outgoing payload and update local state before the server responds:
Livewire.hook('commit', ({ component, commit, respond, succeed, fail }) => {
// `commit` contains the outgoing updates
succeed(({ snapshot, effect }) => {
// runs after a successful server round-trip
console.log('New snapshot received for', component.name);
});
});
Alpine.js Integration: Shared Reactive State
Livewire v3 ships Alpine as a first-class dependency and bridges them through $wire — a reactive proxy that exposes your component's public properties directly to Alpine expressions.
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<span x-show="open" x-text="$wire.status"></span>
<button @click="$wire.refresh()">Refresh</button>
</div>
$wire.status is live: when the Livewire component updates $status on the server, Alpine's reactivity picks up the change automatically via the morph cycle.
Entangle for Two-Way Binding
For cases where Alpine needs to write back to Livewire state, use $wire.entangle():
<div x-data="{ localOpen: $wire.entangle('modalOpen') }">
<button @click="localOpen = true">Open Modal</button>
</div>
Pass .live to push changes on every Alpine mutation rather than waiting for the next Livewire request:
$wire.entangle('search').live
Avoid entangling large objects — every change triggers a network round-trip. Entangle scalar values; derive complex state server-side.
Takeaways
- Always use
wire:keyon looped elements to give the morpher stable anchors and prevent unnecessary DOM replacement. Livewire.hook()is the correct extension point for JS integrations; prefermorph.updatedover broad DOM event listeners.$wireis a reactive proxy — Alpine reads Livewire state without extra glue code.entangle()enables two-way binding but carries a network cost; keep entangled values small and scalar.- The
commithook lets you observe and react to the full request/response lifecycle from JavaScript without monkey-patching Livewire internals.