Optimistic reorder with revert and FLIP animation
Problem
You want drag-and-drop reordering to feel instant: the UI updates immediately without waiting for the server, but if the server returns an error you need to roll the list back to its previous state. You also want a smooth animation when items move.
Solution
Use onBeforeReorder to snapshot element positions for a FLIP animation and call event.setRevert(fn) inside onReorder so sortable.revert() can roll back on failure:
html
<ul id="task-list">
<li data-sort-id="task-1">Design</li>
<li data-sort-id="task-2">Develop</li>
<li data-sort-id="task-3">Review</li>
<li data-sort-id="task-4">Deploy</li>
</ul>css
[data-sort-id] {
transition: transform 200ms ease;
}ts
import { applyReorder, createSortable } from '@vielzeug/dnd';
interface Task {
id: string;
title: string;
}
let tasks: Task[] = [
{ id: 'task-1', title: 'Design' },
{ id: 'task-2', title: 'Develop' },
{ id: 'task-3', title: 'Review' },
{ id: 'task-4', title: 'Deploy' },
];
const listEl = document.getElementById('task-list') as HTMLUListElement;
const sortable = createSortable({
element: listEl,
keyboard: true,
onBeforeReorder: (_from, _to) => {
// Items are still in their pre-commit DOM positions here.
// Snapshot the bounding rect of every item for the FLIP animation.
const rects = new Map<string, DOMRect>();
for (const item of listEl.querySelectorAll<HTMLElement>('[data-sort-id]')) {
rects.set(item.dataset.sortId!, item.getBoundingClientRect());
}
// After the DOM commits, animate from the old rect to the new one.
requestAnimationFrame(() => {
for (const item of listEl.querySelectorAll<HTMLElement>('[data-sort-id]')) {
const before = rects.get(item.dataset.sortId!);
const after = item.getBoundingClientRect();
if (!before) continue;
const dy = before.top - after.top;
if (dy === 0) continue;
// Jump to old position, then transition to new.
item.style.transition = 'none';
item.style.transform = `translateY(${dy}px)`;
requestAnimationFrame(() => {
item.style.transition = 'transform 200ms ease';
item.style.transform = '';
});
}
});
},
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids, setRevert }) => {
const previous = tasks;
tasks = applyReorder(tasks, ids, (t) => t.id);
// Register a revert function — sortable.revert() will call this on failure.
setRevert(() => {
tasks = previous;
renderList(tasks);
});
},
});
async function handleReorder(orderedIds: string[]) {
try {
await api.saveTasks(orderedIds);
} catch {
// Server rejected the new order — roll back the optimistic update.
sortable.revert();
}
}How it works
onBeforeReorder(from, to)fires before the DOM reorders. Record element positions here.- The DOM commits (items move to their new positions).
onReorder({ ids, setRevert })fires. Update your data array optimistically and callsetRevert()with a rollback function.- If the server call fails, call
sortable.revert(). It invokes the stored revert function and clears it so subsequent failures are no-ops.
Only the most recent reorder can be reverted — a new reorder overwrites the stored revert function.
onBeforeReorder fires for both drag and keyboard moves.
Pitfalls
- Do not call
sortable.revert()after a successful save — it is a destructive operation. - If items are removed from the DOM between
onReorderand the server response,renderListmust reconcile the current DOM state before syncing, then callsortable.sync(). - The CSS
transitionon[data-sort-id]must be applied after the initialtranslateYis set (therequestAnimationFramedouble-raf pattern ensures this).