SortableJS + Alpine.js: The DOM Ownership Battle
Drag once, nothing happens. Drag twice, it works. Two libraries fighting over who owns the DOM.
You drag an item... nothing happens. Drag again... now it works. This was a maddening bug in a widget sidebar where users reorder items with SortableJS inside an Alpine.js component.
The Conflict
Here's what happens on the first drag:
- User drags widget A from position 1 to position 3
- SortableJS physically moves the DOM element
- The
onEndhandler updateswidget.positionin Alpine's reactive state enabledWidgetsSortedgetter re-evaluates- Alpine's
x-forre-renders the list based on the new sort order - Alpine puts the DOM back to match its virtual state -- undoing SortableJS's move
The first drag appears to do nothing because Alpine immediately "corrects" the DOM.
The Broken Code
<template x-for="widget in enabledWidgetsSorted" :key="widget.id">
<div class="widget-item" :data-widget-id="widget.id">
<!-- Widget content -->
</div>
</template>
onEnd: (evt) => {
this.widgets.forEach((widget, index) => {
widget.position = index; // Triggers Alpine re-render!
});
this.saveWidgetOrder();
}
The reactive binding :data-widget-id also broke when using forceFallback: true -- SortableJS clones elements outside Alpine's scope, so the cloned node has widget.id in its template but no widget variable.
The Fix: Let SortableJS Own the DOM
The key insight: don't update reactive state after a drag. Let SortableJS's DOM change persist, read the new order from the DOM, and just save it.
<!-- Use x-init instead of :data-* to avoid reactive binding -->
<div class="widget-wrapper" x-init="$el.dataset.widgetId = widget.id">
new Sortable(container, {
animation: 150,
handle: '.widget-drag-handle',
draggable: '.widget-wrapper',
// NO forceFallback -- it clones outside Alpine scope
onEnd: (evt) => {
if (evt.oldIndex === evt.newIndex) return;
// Read order from DOM, don't touch reactive state
const items = container.querySelectorAll('.widget-wrapper');
const widgetIds = Array.from(items)
.map(item => parseInt(item.dataset.widgetId))
.filter(Boolean);
// Save to API only -- order correct on next page load
this.saveWidgetOrder(widgetIds);
}
});
The Rule
When two libraries both want to control DOM state, don't add synchronization -- define ownership. SortableJS owns element positions during drag. Alpine owns initial render and everything else. The database owns persisted state. Respect the boundaries and they work together.
Built while working on PlanPal.
Back to danielships ->