はじめに
Reactでドラッグ&ドロップを実装するとき、何を使っていますか。
GitHubで16,000スター以上、毎月2,300万回以上のnpmダウンロード。dnd-kitは、今最も勢いのあるReact向けD&Dライブラリと言っていいでしょう。
正直なところ、以前はreact-dndを使っていたんですよ。でもあれ、HOC(Higher Order Component)ベースで書くことも多くて、hooks時代のReactには少し古く感じていました。dnd-kitに乗り換えてからは、「これだよ、こういうのが欲しかったんだ」という感じ。30代になって思うのは、ライブラリ選定でも新しいものを毛嫌いせず、実際に試してみることの大切さですね。
dnd-kitとは
dnd-kitは「modern, lightweight, performant, accessible and extensible」をコンセプトにしたReact向けのドラッグ&ドロップツールキットです。
特徴的なのは、useDraggableとuseDroppableというhooksベースのAPIを提供していること。既存のコンポーネントにD&D機能を「後付け」できる設計になっています。
現在の最新バージョンは6.3.1で、MITライセンス。5年以上の歴史があり、43人以上のコントリビューターが開発に参加しています。
特徴・メリット
1. コアが軽い
これ、意外と重要なんですけど、@dnd-kit/coreのバンドルサイズは約10kB(minified)。しかも外部依存がゼロ。
コスパ的に、この軽さは正義です。バンドルサイズを気にするプロジェクトでも採用しやすい。
2. hooksベースで直感的
Providerでラップして、コンポーネントでhooksを呼ぶだけ。React hooksに慣れていれば学習コストはほぼゼロ。
個人的には、この「React wayな設計」が一番のメリットだと思います。
3. アクセシビリティが標準装備
キーボード操作、スクリーンリーダー対応が最初から組み込まれています。アクセシビリティを後から追加するのは大変なので、これは地味にありがたい。
4. 拡張性が高い
Sensors(入力方式)、Modifiers(動作調整)、衝突検出アルゴリズムなど、カスタマイズポイントが豊富。自由度の高さがQOL上がります。
5. パフォーマンスが優秀
DOMのmutationを最小化して、CSS transform(translate3d、scale)で動作を表現。60fps出せる設計になっています。
インストール方法
基本パッケージ
npm install @dnd-kit/core
ソート機能が必要な場合
npm install @dnd-kit/core @dnd-kit/sortable
フルセット
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/modifiers
時短したいなら、必要なパッケージだけインストールすればOK。モノレポ構成なので、使いたい機能だけ入れられます。
基本的な使い方
シンプルなドラッグ&ドロップ
まずは最小構成から。ドラッグ可能な要素とドロップ可能な領域を作ります。
import { DndContext, DragEndEvent } from '@dnd-kit/core'
import { useDraggable, useDroppable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
function DraggableItem({ id }: { id: string }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: id,
})
const style = {
transform: CSS.Translate.toString(transform),
}
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
ドラッグしてね
</div>
)
}
function DroppableArea({ id }: { id: string }) {
const { isOver, setNodeRef } = useDroppable({
id: id,
})
const style = {
backgroundColor: isOver ? '#e0ffe0' : '#f0f0f0',
}
return (
<div ref={setNodeRef} style={style}>
ここにドロップ
</div>
)
}
function App() {
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over) {
console.log(`${active.id} を ${over.id} にドロップ`)
}
}
return (
<DndContext onDragEnd={handleDragEnd}>
<DraggableItem id="item-1" />
<DroppableArea id="drop-zone" />
</DndContext>
)
}
DndContextでラップして、useDraggableとuseDroppableを使う。これだけでD&Dが動く。
ソート可能なリスト
実務で一番使うのはこれじゃないでしょうか。@dnd-kit/sortableを使います。
import { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useState } from 'react'
interface Item {
id: string
name: string
}
function SortableItem({ item }: { item: Item }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: item.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{item.name}
</div>
)
}
function SortableList() {
const [items, setItems] = useState<Item[]>([
{ id: '1', name: 'タスク1' },
{ id: '2', name: 'タスク2' },
{ id: '3', name: 'タスク3' },
])
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
setItems((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id)
const newIndex = items.findIndex((item) => item.id === over.id)
return arrayMove(items, oldIndex, newIndex)
})
}
}
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map((item) => (
<SortableItem key={item.id} item={item} />
))}
</SortableContext>
</DndContext>
)
}
SortableContextでリストをラップして、useSortableフックを使う。arrayMoveユーティリティで配列の並び替えも簡単。
DragOverlayでスムーズなUX
ドラッグ中の要素を別レイヤーで描画すると、より滑らかな体験になります。
import { DndContext, DragOverlay, DragStartEvent } from '@dnd-kit/core'
import { useState } from 'react'
function App() {
const [activeId, setActiveId] = useState<string | null>(null)
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string)
}
const handleDragEnd = () => {
setActiveId(null)
}
return (
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
{/* ドラッグ可能な要素たち */}
<DragOverlay>
{activeId ? <DraggedItemPreview id={activeId} /> : null}
</DragOverlay>
</DndContext>
)
}
DragOverlayを使うと、ドラッグ中の要素がドキュメントフローから外れて描画される。これ、パフォーマンス的にも見た目的にも良い。
実践的なユースケース
カンバンボード
Trelloみたいなカンバンボードを作るケース。複数のレーンがあって、カード間でD&Dできるやつ。
import { DndContext, DragEndEvent, DragOverEvent } from '@dnd-kit/core'
import { SortableContext, arrayMove } from '@dnd-kit/sortable'
import { useState } from 'react'
interface Task {
id: string
title: string
status: 'todo' | 'doing' | 'done'
}
function KanbanBoard() {
const [tasks, setTasks] = useState<Task[]>([
{ id: '1', title: 'タスクA', status: 'todo' },
{ id: '2', title: 'タスクB', status: 'doing' },
{ id: '3', title: 'タスクC', status: 'done' },
])
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event
if (!over) return
const activeTask = tasks.find((t) => t.id === active.id)
const overStatus = over.id as Task['status']
if (activeTask && ['todo', 'doing', 'done'].includes(overStatus)) {
setTasks((tasks) =>
tasks.map((t) =>
t.id === active.id ? { ...t, status: overStatus } : t
)
)
}
}
const columns = ['todo', 'doing', 'done'] as const
return (
<DndContext onDragOver={handleDragOver}>
<div style={{ display: 'flex', gap: '16px' }}>
{columns.map((status) => (
<Column
key={status}
status={status}
tasks={tasks.filter((t) => t.status === status)}
/>
))}
</div>
</DndContext>
)
}
onDragOverイベントで、ドラッグ中にリアルタイムでカラム間移動を処理できる。
ファイルアップローダー
ファイルを並び替えてからアップロードするUI。
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
} from '@dnd-kit/sortable'
function FileUploader() {
const [files, setFiles] = useState<File[]>([])
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
return (
<DndContext sensors={sensors}>
<SortableContext items={files.map((f) => f.name)} strategy={rectSortingStrategy}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)' }}>
{files.map((file) => (
<SortableFileItem key={file.name} file={file} />
))}
</div>
</SortableContext>
</DndContext>
)
}
rectSortingStrategyを使えばグリッドレイアウトでも綺麗に動く。
制限付きドラッグ
特定の範囲内だけでドラッグを許可したい場合。
import { DndContext } from '@dnd-kit/core'
import { restrictToParentElement } from '@dnd-kit/modifiers'
function RestrictedDrag() {
return (
<DndContext modifiers={[restrictToParentElement]}>
{/* 親要素の範囲内でのみドラッグ可能 */}
</DndContext>
)
}
@dnd-kit/modifiersパッケージのmodifierを使えば、移動範囲の制限も簡単。
react-dndからの移行
個人的には、react-dndから移行する価値は十分あると思います。
移行のポイント:
-
hooksベースに統一できる
- HOCパターンから脱却して、コードがスッキリする
-
バンドルサイズが減る
- react-dnd + HTML5 Backendより軽量
-
アクセシビリティが向上
- キーボード操作が標準で付いてくる
-
TypeScriptの型が優秀
- 型推論がしっかり効いてストレスフリー
// react-dnd (before)
const [{ isDragging }, drag] = useDrag(() => ({
type: 'ITEM',
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}))
// dnd-kit (after)
const { isDragging, setNodeRef, listeners, attributes } = useDraggable({
id: 'item',
})
APIがシンプルになって、何をしているか分かりやすい。
まとめ
dnd-kitを導入して感じた変化:
- セットアップ: 必要なパッケージだけ入れればOK
- 学習コスト: hooksが分かれば即使える
- バンドルサイズ: コア10kBで超軽量
- アクセシビリティ: 標準でキーボード対応
- 拡張性: Sensors、Modifiers、衝突検出をカスタマイズ可能
正直なところ、新規プロジェクトでReactのD&Dを実装するならdnd-kit一択ですね。
特にモダンなReact(hooks、TypeScript)で開発しているプロジェクトには最適。「D&Dは実装が面倒」と思っていた人ほど、dnd-kitの良さが分かると思います。
まだ試していない人は、まずソート可能なリストから始めてみてください。思った以上に簡単で驚くはず。