The Morph Plugin For Flicker Free DOM Updates With Livewire And Beyond
AI generated
x-data
Alpine
Alpine.js / Morph Plugin
Flicker Free DOM Updates
How the Alpine Morph plugin intelligently reconciles existing DOM nodes

Alpine.morph() does not simply replace an existing DOM fragment with new HTML, it reconciles both trees node by node and only changes what actually differs. Focus, scroll position, input values, and even a running Alpine component's state survive the process, one reason Livewire uses the plugin internally on every server roundtrip.

10 min read @alpinejs/morph Alpine.morph()

1. The problem Morph solves: naive innerHTML destroys state

The simplest way to update a piece of the page after a server request is container.innerHTML = newHtml. The browser removes the entire old subtree and rebuilds it from scratch, even if content wise only a single number inside a badge actually changed. For the user that means: a currently focused input field loses focus, a running CSS transition snaps back to its start, the scroll position inside a long list container gets reset, and text already typed but not yet submitted in a form field is simply gone.

That is exactly the problem Alpine.morph() solves. Instead of throwing away the old tree and building a new one, Morph compares the existing DOM tree against the freshly delivered HTML string node by node and only changes, in the real DOM, what actually differs: a changed text node, a changed attribute, a newly added or removed child element. Everything else, including focus, scroll position, and running animations, stays untouched.

2. Installation: registering @alpinejs/morph as a plugin

Morph is not part of Alpine's core, it is an official, separate plugin and has to be included explicitly. With an npm build setup, install the @alpinejs/morph package and register it via Alpine.plugin(morph). With a CDN setup, adding one extra script tag before the main Alpine script is enough.

Timing matters here too: registering the plugin has to happen, just like with custom directives and magic properties, before Alpine.start(), otherwise Alpine.morph() simply is not available at runtime.


import Alpine from 'alpinejs'
import morph from '@alpinejs/morph'

Alpine.plugin(morph)
Alpine.start()

// CDN variant: <script defer src=".../morph.min.js"></script>
// must sit BEFORE the main Alpine script

3. Basic syntax: Alpine.morph(el, newHtml, options)

The call follows the pattern Alpine.morph(from, toHtml, options). from is the existing DOM element to update, toHtml is either an HTML string or already a DOM element representing the desired target state, and options is an object of optional callback functions such as key, updating, updated, adding, added, removing, and removed, which let you fine tune the reconciliation.

Internally the algorithm works recursively: if the tag name or an identity recognized through key differs between the old and new node, the whole node gets swapped out. If tag and identity match, only attributes and text content get updated, and the algorithm descends one level into the child nodes to continue the same reconciliation there.


let container = document.querySelector('#product-list')

fetch('/products?filter=active')
  .then(res => res.text())
  .then(newHtml => {
    // Patches existing nodes instead of replacing them
    Alpine.morph(container, newHtml)
  })

4. The key function: correctly matching reordered list items

For a simple list without reordering, Morph's order based matching is usually enough. Once the order of list items changes, though, after a server side sort or filter operation for example, Morph needs an explicit identity per element to recognize that an item merely changed position instead of being deleted and recreated. That is exactly what the key attribute is for, by default Morph reads a plain key HTML attribute per element for this.

This concept is identical to the key prop in React or the :key binding in Vue's v-for: a stable, unique identifier per list item, independent of its current position in the tree. Skip key on a list whose order changes, and Morph falls back to matching by position, which can lead to the wrong DOM node being recognized as reusable, focus ending up on the wrong list item, for example.


<!-- Before sorting -->
<ul id="list">
  <li key="product-3">Camera</li>
  <li key="product-1">Keyboard</li>
</ul>

<!-- After Alpine.morph(list, newHtml) with the reversed order,
     Morph recognizes via key that both li elements merely
     changed position, instead of recreating them -->
<ul id="list">
  <li key="product-1">Keyboard</li>
  <li key="product-3">Camera</li>
</ul>

5. Why focus and Alpine component state survive a morph

The reason a morph preserves so much more state than an innerHTML swap comes down to DOM node identity itself. When Morph recognizes that an existing node can be reused, that exact JavaScript object stays alive in memory, only its attributes and text content get adjusted. The browser therefore has no idea anything happened content wise, focus, scroll position, and running CSS transitions all stay attached to that same, unchanged node.

The same principle applies to Alpine itself: a x-data component's reactive state is managed internally through a structure tied to its specific DOM element, not as a separate object independent of the DOM. As long as Morph recognizes an Alpine component's root element as a reusable node, its entire internal state stays intact automatically, including active event listeners and already set up watchers, with no need for the component to reinitialize.

6. Primary use case: how Livewire uses Morph on every server roundtrip

By far the most common practical home for Morph is Livewire. After every server interaction, a wire:click or a wire:model change for example, Livewire fully rerenders the affected component to HTML on the server and sends that result back to the client. Instead of inserting that HTML via innerHTML, which would cause exactly the problems described earlier on every keystroke inside a bound input field, Livewire uses Alpine.morph() internally to patch only the spots that actually changed.

For Livewire components with lists, the same key logic described above applies, there through the wire:key attribute instead of a plain key attribute, but with identical function: a stable identity per row that survives server side reordering as well.

7. Standalone use outside Livewire

Morph is deliberately built as a standalone, generic plugin and works entirely independent of Livewire. One obvious use case is a simple, server rendered partial update, similar to what htmx offers, but without the extra library: a fetch() call returns an HTML fragment from the server, and Alpine.morph() patches the existing container with it instead of replacing it.

That pays off anywhere an existing container needs periodic updates with fresh server data, without losing focus in a search field that is open at the same time or the scroll position inside the list, a live dashboard that refreshes itself every few seconds through polling, for example.


async function refresh() {
  let html = await fetch('/dashboard/fragment').then(r => r.text())
  // Preserves scroll position and focus inside the dashboard container
  Alpine.morph(document.querySelector('#dashboard'), html)
}

setInterval(refresh, 5000)

8. Limits and pitfalls of Morph

Despite its name, Morph is not a full virtual DOM diffing algorithm like the one in React or Vue, it is a deliberately lean DOM to DOM patching mechanism. Change an element's tag name completely, from div to section for example, and the node still gets fully swapped out, along with all state contained inside it. On very large HTML fragments with thousands of nodes, the recursive reconciliation itself can take noticeable time too, though usually far less than a full rebuild.

The updating and removing callback options let you deliberately exclude individual nodes from the automatic reconciliation, to leave a widget managed by a third party library untouched, for example. Anyone unaware of these callbacks often wonders why a DOM element manipulated from outside suddenly snaps back to its server rendered starting state on the next Morph call, since Morph knows no exceptions by default and consistently reconciles everything present in the delivered HTML.

9. When Morph pays off and when a plain x-show is enough

For purely client side visibility toggles, where already present markup is only shown or hidden, x-show or x-if remains the right, considerably simpler choice, Morph adds no value here because no new HTML arrives from the server. Morph pays off exactly when new HTML genuinely arrives from outside, whether from Livewire, a custom fetch request, or another server rendered fragment, and existing client side state such as focus, scroll position, or form input needs to survive it.

As a rule of thumb: the more often a container gets updated while the user is actively interacting with it, typing, scrolling, or watching an animation for example, the more Morph matters compared to a plain HTML swap. For rare, full page transitions, on the other hand, the difference is barely noticeable to the user, and a plain swap remains the simpler solution.

Aspect Replacing innerHTML Alpine.morph()
Focus and scroll position Lost Preserved
Input values while typing Lost Preserved
x-data component state Reinitialized Preserved as long as the node is reused
Performance on small changes Full rebuild of the subtree Targeted patching of only the differing nodes
Typical use Simple, static swap with no active interaction Livewire, polling dashboards, server side rendering updates

Mironsoft

Alpine.js interactivity for Hyvä frontends

A Hyvä frontend that needs more interactivity, but without React overhead?

We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.

Custom Components

Develop interactive Alpine.js components for specific shop requirements.

Performance Review

Review existing Alpine.js implementations for reactivity pitfalls and performance.

Team Training

Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.

10. Summary

Alpine Morph plugin: the essentials at a glance

Core idea

Alpine.morph() reconciles existing DOM against new HTML and only changes what actually differs.

State survives

Focus, scroll position, input values, and x-data state survive a morph as long as nodes get reused.

key attribute

Needed for reordered lists so Morph matches elements correctly instead of by position.

Not just Livewire

Alpine.morph() works standalone too, for custom fetch based partial updates for example.

11. FAQ: Alpine Morph plugin: the essentials at a glance

1What does Alpine.morph() do differently from plain innerHTML?
Instead of removing the entire old DOM subtree and rebuilding it, Morph compares the old and new HTML trees node by node and only changes the attributes and text content that actually differ in the real DOM.
2Is Morph part of Alpine's core?
No, Morph is a separate, official plugin and has to be installed via @alpinejs/morph and registered with Alpine.plugin(morph) before Alpine.start().
3Why does focus in an input field survive a morph?
Because Morph reuses the existing DOM node instead of replacing it. The browser therefore treats it as an unchanged element, so focus and cursor position stay attached to that exact node.
4What is the key attribute used for in Morph?
For lists whose order changes, key helps Morph recognize an element by a stable identity instead of matching it purely by position, similar to the key prop in React.
5Is Livewire the only thing that uses the Morph plugin?
No, Livewire is the best known home for it because it uses Morph internally on every server roundtrip, but Alpine.morph() can be called entirely standalone too, for custom fetch based partial updates for example.
6Does an Alpine component's state survive a morph?
Yes, as long as Morph recognizes the component's root element as a reusable node, its internal x-data state, including event listeners, stays intact automatically.
7What happens if an element's tag name changes completely?
Then Morph finds no reusability and swaps out the whole node, along with all state contained inside it, exactly as a classic innerHTML swap would.
8Is Alpine.morph() a full virtual DOM algorithm like React's?
No, it is a deliberately lean DOM to DOM patching mechanism with no separate virtual DOM tree, reconciling directly between the existing and the new HTML.
9How can individual elements be excluded from Morph's automatic reconciliation?
Through the updating and removing callback options, which let you deliberately leave certain nodes untouched, a widget managed by a third party library, for example.
10When is x-show the better choice over Morph?
For purely client side visibility toggles on markup that already exists, with no new HTML arriving from outside, Morph adds no value, x-show or x-if remain the simpler choice here.