fc-react-dndnpm ↗
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

src/examples/comparison-example.tsx
'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>
  )
}