Benchmark
fc-react-dnd vs dnd-kit
The same 24-row list, built twice, with an identical re-render counter on every row. The number counts how many times React has re-rendered that row since the last reset.
Reset, then drag once on each side
Both dragged rows climb, and both should — they follow your pointer. The difference is everything else: on the left only the rows actually pushed out of the way re-render; on the right all 24 do, on every pointer move. That is the architecture — a drag store outside React, so a change reaches only the rows it moves. Baseline:
@dnd-kit/core@6.3.1, @dnd-kit/sortable@10.0.0, pinned so the comparison stays reproducible.fc-react-dnd
To pick up a draggable item, press Space or Enter. While dragging, use the arrow keys to move the item. Press Space or Enter again to drop, or press Escape to cancel.
dnd-kit
'use client'
import type { DragEndEvent as DndKitDragEndEvent } from '@dnd-kit/core'
import {
DndContext,
KeyboardSensor as DndKitKeyboardSensor,
PointerSensor as DndKitPointerSensor,
closestCenter as dndKitClosestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable as useDndKitSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { SortEndEvent } from 'fc-react-dnd'
import { applySortEnd, DndProvider, SortableList, useSortable } from 'fc-react-dnd'
import { useCallback, useState } from 'react'
import { listStyle, rowStyle } from './_shared/example-styles'
import { CommitBadge, useCommitCounter } from './_shared/render-counter'
/**
* The same list, twice, with identical re-render counters — so the numbers are like-for-like.
*
* The baseline is classic dnd-kit (`@dnd-kit/core` 6.3.1 + `@dnd-kit/sortable` 10.0.0), what people
* actually ship, pinned so the comparison stays reproducible.
*/
const ITEM_COUNT = 24
const INITIAL_IDS = Array.from({ length: ITEM_COUNT }, (_unused, index) => `item-${index + 1}`)
const OurRow = ({ id }: { id: string }) => {
const { setNodeRef, handleProps, isDragging, style } = useSortable({ id })
const commits = useCommitCounter()
return (
<li>
<button
type="button"
ref={setNodeRef}
{...handleProps}
// One `style` object, against dnd-kit's hand-assembled transform + transition on the
// other side of this comparison.
style={{ ...rowStyle, ...style, opacity: isDragging ? 0.4 : 1 }}
>
{id}
<CommitBadge count={commits} />
</button>
</li>
)
}
const DndKitRow = ({ id }: { id: string }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useDndKitSortable({ id })
const commits = useCommitCounter()
return (
<li>
<button
type="button"
ref={setNodeRef}
{...attributes}
{...listeners}
style={{
...rowStyle,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}}
>
{id}
<CommitBadge count={commits} />
</button>
</li>
)
}
const OurList = () => {
// Ids straight from state — already referentially stable, so there is nothing to memoise.
const [ids, setIds] = useState<readonly string[]>(INITIAL_IDS)
const handleSortEnd = useCallback((event: SortEndEvent) => {
setIds((current) => applySortEnd(current, event, (id) => id))
}, [])
return (
<DndProvider>
<SortableList items={ids} onSortEnd={handleSortEnd}>
<ul style={listStyle}>
{ids.map((id) => (
<OurRow key={id} id={id} />
))}
</ul>
</SortableList>
</DndProvider>
)
}
const DndKitList = () => {
const [ids, setIds] = useState(INITIAL_IDS)
const sensors = useSensors(
useSensor(DndKitPointerSensor),
useSensor(DndKitKeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const handleDragEnd = (event: DndKitDragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
setIds((current) =>
arrayMove(current, current.indexOf(String(active.id)), current.indexOf(String(over.id))),
)
}
return (
<DndContext
sensors={sensors}
collisionDetection={dndKitClosestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
<ul style={listStyle}>
{ids.map((id) => (
<DndKitRow key={id} id={id} />
))}
</ul>
</SortableContext>
</DndContext>
)
}
export const ComparisonExample = () => {
// The counters only climb, so after a few drags both sides read as large numbers whose ratio
// means nothing. Remounting via `key` is what makes a comparison a comparison: one drag on each
// side, from zero, is the only reading that answers "how much work per drag?".
const [runId, setRunId] = useState(0)
return (
<div className="flex flex-col gap-4">
<button
type="button"
onClick={() => setRunId((current) => current + 1)}
className="w-fit rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
>
Reset counters, then drag once on each side
</button>
<div className="grid gap-6 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<h3 className="font-mono text-sm font-semibold text-zinc-900">fc-react-dnd</h3>
<OurList key={runId} />
</div>
<div className="flex flex-col gap-2">
<h3 className="font-mono text-sm font-semibold text-zinc-900">dnd-kit</h3>
<DndKitList key={runId} />
</div>
</div>
</div>
)
}