🌲
【React + TypeScript】dnd-kitを使って、ドラッグアンドドロップを実装する。
初めに
【React + TypeScript】で、dnd-kitを使ってドラッグアンドドロップを実装していきたいと思います。
dnd-kitとは?
以下にdnd-kitについて、簡単にまとめます。
- 軽量かつモダンな React 用ドラッグ&ドロップライブラリ
- TypeScript 対応で型安全
- 高いカスタマイズ性:柔軟にUIや挙動を制御可能
- アクセシビリティ対応:キーボード操作やスクリーンリーダーを考慮
- 並べ替え(Sortable)や階層構造のDnDも可能
今回の実装の概要
今回の実装では、同じ親ノード内での子ノードの並び替え(順序変更)が可能です。しかし、異なる親ノード間での子ノードの移動(親の変更)はできないようになっています。つまり、ある親ノードの子どもたちの間で順番を変えることはできるけど、Aの子だったノードをBの子にする、という移動はできない実装にしました。
実装編
TreeView.tsx
import { useState } from 'react'
import {
DndContext,
closestCenter,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { TreeNode } from './TreeNode'
import { treeData as initialTreeData } from '../data/TreeData'
import type { TreeItem } from '../types/TreeItem'
// フラット化してidと親idのマッピングを作成(ドロップ時の処理に必要)
//ツリー構造をフラット(一次元)な配列に変換する関数。各項目に親IDと深さの情報を持たせる
const flattenTree = (
items: TreeItem[],
parentId: string | null = null,
depth: number = 0
): { item: TreeItem; parentId: string | null; depth: number }[] => {
return items.flatMap((item) => [
{ item, parentId, depth },
...(item.children ? flattenTree(item.children, item.id, depth + 1) : []),
]) //各ノードに対して、再帰的に子供も展開し、すべてのノードをフラットに列挙。
}
// 親の下で並び替え
const moveItemWithinParent = (
items: TreeItem[],
parentId: string | null,
oldIndex: number,
newIndex: number
): TreeItem[] => {
if (parentId === null) {
return arrayMove(items, oldIndex, newIndex)
}
return items.map((item) =>
item.id === parentId && item.children
? { ...item, children: arrayMove(item.children, oldIndex, newIndex) }
: {
...item,
children: item.children
? moveItemWithinParent(item.children, parentId, oldIndex, newIndex)
: undefined,
}
)
}
export const TreeView = () => {
const [tree, setTree] = useState<TreeItem[]>(initialTreeData)
const flattened = flattenTree(tree)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const activeEntry = flattened.find((entry) => entry.item.id === active.id)
const overEntry = flattened.find((entry) => entry.item.id === over.id)
if (!activeEntry || !overEntry) return
if (activeEntry.parentId !== overEntry.parentId) return
const siblings = flattened.filter(
(entry) => entry.parentId === activeEntry.parentId
)
const oldIndex = siblings.findIndex((entry) => entry.item.id === active.id)
const newIndex = siblings.findIndex((entry) => entry.item.id === over.id)
const updated = moveItemWithinParent(tree, activeEntry.parentId, oldIndex, newIndex)
setTree(updated)
}
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="p-4">
<SortableContext
items={tree.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{tree.map((item) => (
<TreeNode key={item.id} item={item} />
))}
</SortableContext>
</div>
</DndContext>
)
}
解説
- @dnd-kitを使って階層構造(ツリー構造)のリストをドラッグ&ドロップで並び替えるためのコンポーネント
-
DndContext
:dnd-kitのドラッグ&ドロップ全体を包むコンテナ。 -
closestCenter
:アイテムのドロップ先を決定するための衝突検出アルゴリズム。 -
DragEndEvent
:ドラッグ終了時のイベント型。 -
arrayMove
:配列内の要素の順序を入れ替える関数。 -
SortableContext
:ドラッグ可能な要素を囲むコンテナ。 -
verticalListSortingStrategy
:縦並びの並び替え戦略を指定。 - この実装では「同じ親の中でのみ並び替えができる」仕様で、階層の移動(親を変更する)は許可されていない。
TreeNode.tsx
import React from 'react'
import {
useSortable,
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { TreeItem } from '../types/TreeItem'
type TreeNodeProps = {
item: TreeItem //表示するノードのデータ
depth?: number
}
//props から item(ノードデータ)と depth(インデント用)を受け取る。depth はデフォルトで0。
export const TreeNode: React.FC<TreeNodeProps> = ({ item, depth = 0 }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id })
//ドラッグ時のスタイル
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const isParent = !!item.children && item.children.length > 0
// 子要素の横幅を親より少し狭くする
const maxWidthClass = depth === 0 ? 'max-w-md' : 'max-w-sm'
const bgColor = isParent ? 'bg-blue-100' : 'bg-blue-50'
const fontSize = isParent ? 'text-base' : 'text-sm'
const padding = 'p-3'
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="mb-2"
>
<div
className={`${bgColor} ${maxWidthClass} w-full rounded-lg shadow border ${fontSize} ${padding}`}
style={{ marginLeft: depth * 16 }}
>
{item.name}
</div>
{item.children && item.children.length > 0 && (
<SortableContext
items={item.children.map((child) => child.id)}
strategy={verticalListSortingStrategy}
>
<div>
{item.children.map((child) => (
<TreeNode key={child.id} item={child} depth={depth + 1} />
))}
</div>
</SortableContext>
)}
</div>
)
}
解説
- @dnd-kitを使って階層構造(ツリー構造)のリストをドラッグ&ドロップで並び替えるためのコンポーネント
- transform と transition を適用して、ドラッグ中の見た目を設定。ドラッグ中は opacity: 0.5 で少し透過。
- 再帰的なコンポーネント構造になっており、どんな階層でも表示&並び替えできる。(ただし、親変更は不可)
TreeData.ts
import type { TreeItem } from '../types/TreeItem'
export const treeData: TreeItem[] = [
{
id: 'fruits',
name: '🍎 Fruits',
isCategory: true,
children: [
{ id: 'banana', name: 'Banana' },
{ id: 'apple', name: 'Apple' },
{ id: 'orange', name: 'Orange' },
],
},
{
id: 'vegetables',
name: '🥦 Vegetables',
isCategory: true,
children: [
{ id: 'carrot', name: 'Carrot' },
{ id: 'broccoli', name: 'Broccoli' },
{ id: 'spinach', name: 'Spinach' },
],
},
{
id: 'grains',
name: '🍞 Grains',
isCategory: true,
children: [
{ id: 'bread', name: 'Bread' },
{ id: 'rice', name: 'Rice' },
],
},
]
解説
- 実際に表示・操作対象となるツリー構造のデータ (treeData) 。各ノードは TreeItem 型で定義されており、階層的な構造(親子関係) を持っている。
TreeItem.ts
export type TreeItem = {
id: string
name: string
children?: TreeItem[]
isCategory?: boolean
}
解説
-
export type TreeItem = { ... }
は型定義であり、TypeScriptにおいて「オブジェクトの構造を指定する」もの。この定義によって、TreeItemという名前の 型(インターフェースのようなもの) が作られている。
App.tsx
import { TreeView } from './components/TreeView'
function App() {
return (
<main className="min-h-screen bg-gray-100 text-gray-800">
<h1 className="text-2xl font-bold p-4">🍎 食べ物ツリー</h1>
<TreeView />
</main>
)
}
export default App
実行結果
まとめ
-
基本的な構成:
DndContext
で全体を囲み、SortableContext
とuseSortable
フックを使ってドラッグ&ドロップの基本的な仕組みを構築しました。 -
データ構造の工夫:
flattenTree
関数でネストしたツリー構造をフラットな配列に変換することで、各アイテムの親子関係や位置の特定を容易にしました。これは、複雑な階層構造を扱う際の定石テクニックです。 -
ロジックによる制御:
handleDragEnd
イベント内で、ドラッグ元(active)とドロップ先(over)のparentIdを比較し、異なる場合は処理を中断することで、「親をまたいだアイテム移動の禁止」という要件を実現しました。
Discussion