Drag-and-Drop Row Reordering in React Tables with dnd-kit
A practical guide to letting users drag rows into the order they want, using @dnd-kit alongside TanStack Table. Covers sensors, accessibility, persisting the new order, and how to make drag and column-sort coexist cleanly.
Drag-and-Drop Row Reordering in React Tables with dnd-kit
Most data tables sort rows by a column — clicking "Date" to flip ascending/descending. But sometimes the order itself is the data: a kanban-like priority list, a CV's project ordering, a queue of items the user wants processed in their own sequence. That kind of order has to be set by hand, and the natural gesture is drag and drop.
This post walks through wiring up drag-to-reorder rows in a React table, using @dnd-kit for the drag mechanics and TanStack Table for the row model. The same pattern works with a plain <table> if you don't want a table library.
Why dnd-kit
@dnd-kit is a small, accessible drag-and-drop toolkit that doesn't depend on the HTML5 drag API. That matters for tables, because the HTML5 drag API has well-known issues with <tr> elements: ghost previews collapse, drop zones flicker, and styling the drag handle is awkward.
@dnd-kit uses pointer events instead, ships with a sortable preset, and has built-in keyboard support so the feature still works for users who don't drag with a mouse.
Install
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/modifiers
Four packages, but each is tiny:
@dnd-kit/core— the drag context and sensors.@dnd-kit/sortable— the sortable list preset (what you want for table rows).@dnd-kit/utilities— small helpers, mostly theCSStransform builder.@dnd-kit/modifiers— constraints like "only allow vertical dragging."
The mental model
Three things participate in a sortable list:
- A
DndContextthat owns the drag interaction and exposes theonDragEndcallback. - A
SortableContextthat holds the array of item ids and tracks the active/over state. - Sortable items — each row registers itself with
useSortable({ id }).
The library doesn't move your data. When the drop happens, it tells you the active id and the over id, and you reorder your own array.
Minimal example
Start without a table library — just an array and a list of <div>s:
import {
DndContext,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { useState } from "react";
type Item = { id: string; label: string };
const initial: Item[] = [
{ id: "a", label: "First" },
{ id: "b", label: "Second" },
{ id: "c", label: "Third" },
];
export function SortableList() {
const [items, setItems] = useState(initial);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
);
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setItems((current) => {
const oldIndex = current.findIndex((it) => it.id === active.id);
const newIndex = current.findIndex((it) => it.id === over.id);
return arrayMove(current, oldIndex, newIndex);
});
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={onDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<ul>
{items.map((it) => (
<SortableRow key={it.id} item={it} />
))}
</ul>
</SortableContext>
</DndContext>
);
}
function SortableRow({ item }: { item: Item }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: item.id });
return (
<li
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}}
>
<button {...attributes} {...listeners} aria-label="Drag handle">
<GripVertical size={16} />
</button>
{item.label}
</li>
);
}
Two things worth noting:
activationConstraint: { distance: 4 }— drag only starts after the pointer moves 4px. Without this, every click on the handle starts a drag.- The drag handle is a separate element.
attributesandlistenersgo on the handle, not the whole row. That way you can put buttons or links inside the row and they stay clickable.
Adapting for a table
Tables add one wrinkle: items live inside <tbody>, and you need stable ids that match TanStack Table's row ids. Use getRowId to derive them from your data so they survive re-renders.
const table = useReactTable({
data,
columns,
getRowId: (row) => row.id, // critical: TanStack Table uses array index by default
getCoreRowModel: getCoreRowModel(),
});
const rowIds = useMemo(
() => table.getRowModel().rows.map((r) => r.id),
[table.getRowModel().rows]
);
Then wrap the table body in SortableContext:
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<table>
<thead>
{/* unchanged */}
</thead>
<tbody>
<SortableContext items={rowIds} strategy={verticalListSortingStrategy}>
{table.getRowModel().rows.map((row) => (
<SortableTableRow key={row.id} row={row} />
))}
</SortableContext>
</tbody>
</table>
</DndContext>
The <SortableTableRow> is just a <tr> that calls useSortable:
function SortableTableRow<T>({ row }: { row: Row<T> }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: row.id });
return (
<tr
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.7 : 1,
position: "relative",
zIndex: isDragging ? 10 : undefined,
}}
>
<td>
<button {...attributes} {...listeners} aria-label="Drag to reorder">
<GripVertical size={14} />
</button>
</td>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
}
Persisting the new order
Reordering UI state is the easy half. Persisting it is where most implementations cut corners.
The pattern that scales: when the drop fires, optimistically reorder client state, then send the new order to your backend. If the request fails, revert.
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = items.findIndex((it) => it.id === active.id);
const newIndex = items.findIndex((it) => it.id === over.id);
const reordered = arrayMove(items, oldIndex, newIndex);
setItems(reordered); // optimistic
saveOrder(reordered.map((it) => it.id)).catch(() => {
setItems(items); // revert on failure
toast.error("Couldn't save the new order");
});
};
On the backend side, the simplest contract is a single endpoint that accepts an ordered list of ids and rewrites a position (or order) integer column for each. Don't try to do partial updates — full replacement is easier to reason about and matches what the user just did.
Keyboard accessibility
@dnd-kit has a KeyboardSensor you can add alongside PointerSensor:
import { KeyboardSensor } from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
Once that's wired, focusing a drag handle and pressing Space picks the item up. Arrow keys move it. Space again drops it. This is free — no extra component code.
When sorting and dragging conflict
If your table also supports column sorting, drag-to-reorder doesn't make sense while a sort is active — the user-set order would be invisible. The clean resolution is to disable the drag handle when sorting is on, and tell the user why:
const sortingActive = table.getState().sorting.length > 0;
// In the drag handle:
<button
{...(sortingActive ? {} : attributes)}
{...(sortingActive ? {} : listeners)}
disabled={sortingActive}
aria-label={sortingActive ? "Clear sort to reorder" : "Drag to reorder"}
/>
Common pitfalls
Forgetting getRowId. TanStack Table defaults to array-index ids. After a reorder, ids change, useSortable re-mounts, and your animations stutter or fail.
Putting listeners on the row instead of the handle. Now clicking anywhere on the row starts a drag, including buttons and links inside it.
Skipping activationConstraint. Tap-to-drag activates on every click. Set a distance (4–8px) or a delay (150ms) to disambiguate from a normal click.
Letting the drop ghost stretch full-screen. restrictToVerticalAxis from @dnd-kit/modifiers keeps the drag preview pinned to the column it started in. Without it, you can drag a row sideways into other parts of the layout.
Re-creating rowIds on every render. useSortable compares by reference. Wrap the array in useMemo.
Where to take it from here
- The @dnd-kit docs cover sensors, accessibility, and the drag overlay (a separately rendered preview that's invaluable for nested or absolutely-positioned items).
- For grouped lists where items can move between lists (kanban boards), use multiple
SortableContextwrappers and checkover.data.currentinonDragEnd. - The TanStack Table examples gallery has a full row-drag example you can copy verbatim.
The setup looks like a lot the first time, but the moving parts are stable: a context, a sortable wrapper, a row component that registers itself. Once you've shipped one drag-to-reorder feature, the next one is mostly copy-paste.
Rate this post
All fields are optional. Just stars is fine.