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:

  1. User drags widget A from position 1 to position 3
  2. SortableJS physically moves the DOM element
  3. The onEnd handler updates widget.position in Alpine's reactive state
  4. enabledWidgetsSorted getter re-evaluates
  5. Alpine's x-for re-renders the list based on the new sort order
  6. 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.