🌲

【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で全体を囲み、SortableContextuseSortableフックを使ってドラッグ&ドロップの基本的な仕組みを構築しました。

  • データ構造の工夫: flattenTree関数でネストしたツリー構造をフラットな配列に変換することで、各アイテムの親子関係や位置の特定を容易にしました。これは、複雑な階層構造を扱う際の定石テクニックです。

  • ロジックによる制御: handleDragEndイベント内で、ドラッグ元(active)とドロップ先(over)のparentIdを比較し、異なる場合は処理を中断することで、「親をまたいだアイテム移動の禁止」という要件を実現しました。

Discussion