Livewire v3 Internals: Morph Markers, JS Hooks, and Alpine Integration
#livewire #laravel #alpine #frontend

Livewire v3 Internals: Morph Markers, JS Hooks, and Alpine Integration

3 min read Mohamed Said Mohamed Said

How Livewire v3 Actually Updates the DOM

Most engineers treat Livewire as a black box: PHP changes state, the browser updates. The reality is more nuanced, and understanding the internals saves hours of debugging flickering inputs, lost Alpine state, and mysterious re-render loops.

Morph Markers and the Diffing Algorithm

After every network round-trip, Livewire receives a fresh HTML snapshot from the server. Rather than replacing the entire subtree, it runs a morph operation — a DOM diffing algorithm that walks the existing nodes and the new HTML in parallel, applying the minimum set of mutations.

The key mechanism is morph markers: invisible HTML comments injected around dynamic regions.

<!-- [if BLOCK]><![endif] -->
<div wire:key="item-1">...</div>
<!-- [if ENDBLOCK]><![endif] -->

These comments act as stable anchors. Without wire:key, Livewire falls back to positional matching, which is why reordering a list without keys causes inputs to swap values or Alpine components to lose their state.

Rule of thumb: any element inside a @foreach that carries user input or Alpine state needs wire:key.

@foreach($items as $item)
    <div wire:key="item-{{ $item->id }}">
        <input x-data="{ open: false }" x-model="open" />
    </div>
@endforeach

The JavaScript Lifecycle Hooks

Livewire v3 exposes a first-class JS hook system via Livewire.hook(). These are not undocumented internals — they are the intended extension point for packages and custom integrations.

import { Livewire } from '../../vendor/livewire/livewire/dist/livewire.esm';

Livewire.hook('request', ({ uri, options, payload, respond, succeed, fail }) => {
    // Mutate outgoing payload or intercept the response
    options.headers['X-App-Version'] = window.APP_VERSION;
});

Livewire.hook('commit', ({ component, commit, respond, succeed, fail }) => {
    succeed(({ snapshot, effect }) => {
        // Runs after the server responds and before the DOM is morphed
        console.log('Component updated:', component.name);
    });
});

Livewire.hook('morph.updating', ({ el, toEl, component }) => {
    // Preserve a third-party widget's internal state before the node is replaced
    if (el.dataset.preserveScroll) {
        toEl.dataset.scrollTop = el.scrollTop;
    }
});

The morph.updating / morph.updated pair is the correct place to preserve non-Alpine state (e.g., a CodeMirror instance, a Flatpickr calendar) across re-renders.

Alpine Integration: $wire and entangle

Alpine and Livewire share the same DOM, but they maintain separate reactive systems. The bridge is $wire — a JS proxy injected into every Alpine component that lives inside a Livewire component.

<div x-data="{ localCount: $wire.entangle('count') }">
    <button @click="localCount++">Increment</button>
    <span x-text="localCount"></span>
</div>

entangle creates a two-way binding: Alpine's reactive property and the Livewire server property stay in sync. By default, every change triggers a network request. Use .live explicitly when you need that, and omit it when you only want the value synced on the next natural request.

<!-- Syncs on next Livewire request, not immediately -->
<div x-data="{ name: $wire.entangle('name') }">...</div>

<!-- Triggers a request on every keystroke -->
<div x-data="{ name: $wire.entangle('name').live }">...</div>

Avoiding Alpine State Loss

The most common complaint: "my Alpine dropdown closes on every Livewire update." The fix is almost always one of:

  1. Add wire:key to the element carrying x-data.
  2. Move ephemeral UI state (open/closed, tab index) into Alpine only — never into a Livewire property — so the morph algorithm sees no change on that node.
  3. Use x-ignore on subtrees Livewire should never touch.
<div x-ignore>
    <!-- Livewire will not morph anything inside here -->
    <div x-data="richEditor()">...</div>
</div>

Takeaways

  • Morph markers + wire:key are the foundation of stable re-renders; missing keys cause positional mismatches.
  • Livewire.hook('morph.updating') is the right escape hatch for preserving third-party widget state.
  • $wire.entangle without .live is almost always the correct default — it avoids unnecessary round-trips.
  • x-ignore is a surgical tool for opting entire subtrees out of Livewire's diffing.
  • Understanding the commit lifecycle (request → server → succeed → morph) lets you build reliable integrations without monkey-patching.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Why does my Alpine x-data component reset every time Livewire re-renders?
Livewire's morph algorithm replaces nodes it cannot match by key. Add a stable `wire:key` to the element carrying `x-data`. If the element must be fully excluded from morphing, wrap it with `x-ignore`.
Q02 When should I use `$wire.entangle` versus a plain `wire:model`?
`wire:model` is for standard HTML inputs and syncs via Livewire's own event listeners. Use `$wire.entangle` when Alpine needs to read or write a Livewire property inside an `x-data` object — for example, driving computed Alpine state from server-side data.
Q03 Is `Livewire.hook()` stable across minor versions?
The hook API is part of Livewire v3's public JS surface and is used by first-party packages like Flux. It is as stable as any documented API, but always review the changelog when upgrading minor versions, as hook names can be refined.

Continue reading

More Articles

View all