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

Laravel 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 developers treat Livewire as a black box: PHP changes state, the page updates. But understanding the internals pays dividends when you're debugging a flicker, fighting a lost cursor position, or integrating a third-party JS widget.

Morph Markers and the Diffing Algorithm

After every network round-trip, Livewire receives an HTML snapshot from the server and must apply it to the live DOM without destroying unrelated nodes. It does this through morphing — a targeted diff-and-patch strategy rather than a full innerHTML replacement.

Livewire v3 ships its own @livewire/morph package (used internally) that walks the old and new DOM trees in parallel. To anchor elements across renders it relies on wire:key:

@foreach ($items as $item)
    <div wire:key="item-{{ $item->id }}">
        {{ $item->name }}
    </div>
@endforeach

Without wire:key, the morpher falls back to positional matching. This is why reordering a list without keys causes inputs to retain stale values — the morpher patches text nodes but leaves the <input> element in place.

Morph markers are invisible HTML comments injected around dynamic regions:

<!-- [if BLOCK]><![endif] -->
<div>...</div>
<!-- [if ENDBLOCK]><![endif] -->

These comments let the differ locate stable boundaries even when surrounding markup shifts. If you strip HTML comments in a build pipeline or a CDN transform, morphing breaks silently.

JavaScript Lifecycle Hooks

Livewire v3 exposes a rich JS hook API via Livewire.hook(). Hooks fire at well-defined points in the request/response cycle:

Livewire.hook('request', ({ uri, options, payload, respond, succeed, fail }) => {
    // Mutate payload before it leaves the browser
    payload.fingerprint.locale = document.documentElement.lang;

    succeed(({ snapshot, effects }) => {
        // Inspect the server snapshot after a successful response
        console.log('Updated snapshot:', snapshot);
    });
});

Other useful hooks:

| Hook | When it fires | |---|---| | component.init | After a component mounts | | element.init | After each element is processed by the morpher | | morph.updating | Before a node is patched | | morph.updated | After a node is patched | | commit | Wraps the full request/response cycle |

The morph.updating hook is the right place to tell Livewire to skip a node managed by a third-party library:

Livewire.hook('morph.updating', ({ el, toEl, skip }) => {
    if (el.hasAttribute('data-chart')) skip();
});

Calling skip() preserves the existing DOM node entirely, preventing your Chart.js canvas from being wiped on every re-render.

Alpine Integration: $wire and Entangle

Livewire v3 bundles Alpine and exposes $wire as a magic property inside any Alpine component that lives inside a Livewire component:

<div x-data="{ open: false }">
    <button @click="$wire.toggleSidebar()">Toggle</button>
    <span x-text="$wire.entangle('count')"></span>
</div>

$wire.entangle('count') creates a two-way reactive binding between an Alpine ref and a Livewire public property. Under the hood, entangle registers an Alpine effect that watches the Livewire snapshot for changes to count and propagates them into Alpine's reactive system — and vice versa.

For read-heavy bindings where you don't need to push changes back to the server, use the .live modifier sparingly and prefer $wire.get('count') inside computed Alpine properties to avoid unnecessary round-trips.

<div x-data="{ get total() { return $wire.get('total') } }">
    <span x-text="total"></span>
</div>

This reads from the local snapshot without triggering a network request.

Practical Takeaways

  • Always add wire:key to looped elements — positional morphing causes subtle input bugs.
  • Strip HTML comments only after confirming your pipeline doesn't touch Livewire responses.
  • Use morph.updating + skip() to protect third-party JS widgets from being overwritten.
  • Prefer $wire.get() over entangle when you only need one-way reactivity to avoid extra round-trips.
  • The commit hook is the cleanest place to add global request telemetry or auth token refresh logic.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Why does my input lose its value after a Livewire re-render?
This is almost always a missing `wire:key` on a looped element. Without a stable key, the morpher matches elements by position and patches text content while leaving the existing input node in place, causing stale values to persist.
Q02 How do I prevent Livewire from overwriting a DOM node managed by a JS library like Chart.js?
Use the `morph.updating` JS hook and call `skip()` when the element matches your widget. This tells the morpher to leave that node completely untouched during the patch cycle.
Q03 What is the difference between `$wire.entangle()` and `$wire.get()` in Alpine?
`entangle` creates a two-way reactive binding that can push changes back to the server, while `$wire.get()` reads the current value from the local snapshot without triggering a network request. Use `get()` for display-only bindings to reduce round-trips.

Continue reading

More Articles

View all