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.
Table of Contents
- 1. The problem Morph solves: naive innerHTML destroys state
- 2. Installation: registering @alpinejs/morph as a plugin
- 3. Basic syntax: Alpine.morph(el, newHtml, options)
- 4. The key function: correctly matching reordered list items
- 5. Why focus and Alpine component state survive a morph
- 6. Primary use case: how Livewire uses Morph on every server roundtrip
- 7. Standalone use outside Livewire
- 8. Limits and pitfalls of Morph
- 9. When Morph pays off and when a plain x-show is enough
- 10. Summary
- 11. FAQ
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.