React DnDでドラッグ&ドロップ|インタラクティブUIの実装方法

React DnDを使ってドラッグ&ドロップ機能を実装する方法を解説。基本的な使い方から高度なカスタマイズまで、実用的なUIパターンを詳しく紹介

Learning Next 運営
36 分で読めます

みなさん、Webアプリを使っていてこんなことを思ったことはありませんか?

「ファイルをドラッグ&ドロップできたらもっと便利なのに」
「タスクカードを直感的に移動できるUIを作りたい」
「React DnDって聞いたことあるけど、難しそう」

ドラッグ&ドロップ機能があると、ユーザー体験が格段に向上しますよね。 でも実装は複雑そうで、なかなか手を出しにくいと感じているかもしれません。

この記事では、React DnDを使って本格的なドラッグ&ドロップ機能を作る方法をお教えします。 基本的な使い方から実用的なUIパターンまで、わかりやすく解説していきますね。

React DnDって何?

React DnDは、Reactアプリでドラッグ&ドロップ機能を簡単に実装できるライブラリです。 複雑な処理を隠して、直感的なAPIを提供してくれます。

基本的な仕組み

React DnDの核となる3つの要素を理解しましょう。

import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

function App() {
  return (
    <DndProvider backend={HTML5Backend}>
      <DragSource />
      <DropTarget />
    </DndProvider>
  );
}

この例では、DndProviderでアプリ全体をラップしています。 backend={HTML5Backend}でHTML5のドラッグ&ドロップAPIを使用することを指定します。

DragSourceドラッグできる要素DropTargetドロップできる場所です。

ドラッグ可能な要素を作る

useDragフックを使って、ドラッグできる要素を作ります。

import { useDrag } from 'react-dnd';

function DragSource() {
  const [{ isDragging }, drag] = useDrag({
    type: 'item',
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  });

  return (
    <div ref={drag} style={{ opacity: isDragging ? 0.5 : 1 }}>
      ドラッグできるアイテム
    </div>
  );
}

useDragから2つの要素を取得しています。

  • isDragging: ドラッグ中かどうかの状態
  • drag: 要素に割り当てるref

type: 'item'で、このアイテムの種類を指定します。 ドロップターゲット側で、どの種類のアイテムを受け入れるかを決められます。

ドロップターゲットを作る

useDropフックを使って、ドロップを受け入れる場所を作ります。

import { useDrop } from 'react-dnd';

function DropTarget() {
  const [{ isOver }, drop] = useDrop({
    accept: 'item',
    collect: (monitor) => ({
      isOver: monitor.isOver(),
    }),
  });

  return (
    <div ref={drop} style={{ backgroundColor: isOver ? 'lightblue' : 'white' }}>
      ここにドロップ
    </div>
  );
}

accept: 'item'で、type: 'item'のアイテムのみ受け入れるように設定しています。 isOverで、要素の上にドラッグアイテムがあるかどうかがわかります。

なぜReact DnDを選ぶの?

他のライブラリと比較してみましょう。

React DnDは高度な制御が可能で、複雑なUIパターンに対応できます。 react-beautiful-dndはリスト向けで使いやすいですが、機能が限定的です。 ネイティブHTML5 APIは基本的な機能のみです。

React DnDは複雑な要件にも対応できる柔軟性が魅力です。

最初の一歩:環境構築

実際にReact DnDを使ってみましょう。 順番に進めていけば、必ず動かせますよ。

必要なパッケージをインストール

まず、必要なライブラリをインストールします。

npm install react-dnd react-dnd-html5-backend

基本的にはこの2つがあれば大丈夫です。

モバイル対応も必要な場合は、追加で以下をインストールします。

npm install react-dnd-touch-backend

アプリの基本設定

プロジェクトの初期設定を行います。

// src/App.js
import React from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import DragDropExample from './components/DragDropExample';

function App() {
  return (
    <DndProvider backend={HTML5Backend}>
      <div className="App">
        <h1>React DnD Example</h1>
        <DragDropExample />
      </div>
    </DndProvider>
  );
}

export default App;

DndProviderでアプリ全体をラップするのがポイントです。 これで、子コンポーネントでドラッグ&ドロップが使えるようになります。

最初のドラッグ&ドロップを作ろう

シンプルな例から始めましょう。

// src/components/DragDropExample.js
import React, { useState } from 'react';
import { useDrag, useDrop } from 'react-dnd';

const ItemTypes = {
  CARD: 'card',
};

まず、アイテムの種類を定数で定義します。 こうすることで、タイプミスを防げます。

function DraggableCard({ id, text }) {
  const [{ isDragging }, drag] = useDrag(() => ({
    type: ItemTypes.CARD,
    item: { id, text },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  }));

  return (
    <div
      ref={drag}
      style={{
        opacity: isDragging ? 0.5 : 1,
        fontSize: 25,
        fontWeight: 'bold',
        cursor: 'move',
        backgroundColor: '#f0f0f0',
        padding: '10px',
        margin: '5px',
        borderRadius: '5px',
        border: '1px solid #ccc',
      }}
    >
      {text}
    </div>
  );
}

ドラッグ可能なカードコンポーネントです。 item: { id, text }で、ドラッグ時に渡すデータを指定しています。

ドラッグ中は透明度を下げて、視覚的にフィードバックを与えています。

function DropZone({ onDrop, children }) {
  const [{ isOver }, drop] = useDrop(() => ({
    accept: ItemTypes.CARD,
    drop: (item) => {
      onDrop(item);
    },
    collect: (monitor) => ({
      isOver: monitor.isOver(),
    }),
  }));

  return (
    <div
      ref={drop}
      style={{
        backgroundColor: isOver ? 'lightgreen' : 'lightgray',
        minHeight: '200px',
        padding: '20px',
        margin: '10px',
        borderRadius: '5px',
        border: '2px dashed #999',
      }}
    >
      <h3>ドロップゾーン</h3>
      {children}
    </div>
  );
}

ドロップゾーンコンポーネントです。 drop: (item) => { onDrop(item); }で、ドロップ時に親コンポーネントに通知します。

ホバー中は背景色を変えて、ドロップ可能であることを示しています。

デスクトップとモバイルの両対応

デバイスに応じて適切なバックエンドを選択しましょう。

// src/utils/dndBackend.js
import { HTML5Backend } from 'react-dnd-html5-backend';
import { TouchBackend } from 'react-dnd-touch-backend';

const isMobile = () => {
  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
    navigator.userAgent
  );
};

export const getBackend = () => {
  return isMobile() ? TouchBackend : HTML5Backend;
};

デスクトップではHTML5Backend、モバイルではTouchBackendを使います。 これで、すべてのデバイスで快適に操作できます。

実用的なUIパターンを作ろう

よく使われるドラッグ&ドロップのパターンを実装してみましょう。 実際のプロジェクトでも使える内容ですよ。

カンバンボード(タスク管理)

タスク管理でよく見るカンバンボードを作ります。

// src/components/KanbanBoard.js
import React, { useState } from 'react';
import { useDrag, useDrop } from 'react-dnd';

const ItemTypes = {
  TASK: 'task',
};

タスクを表すカードコンポーネントから作りましょう。

function TaskCard({ task, index, moveTask }) {
  const [{ isDragging }, drag] = useDrag({
    type: ItemTypes.TASK,
    item: { id: task.id, index, columnId: task.columnId },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  });

  return (
    <div
      ref={drag}
      style={{
        opacity: isDragging ? 0.5 : 1,
        backgroundColor: '#fff',
        border: '1px solid #ddd',
        borderRadius: '4px',
        padding: '12px',
        margin: '8px 0',
        cursor: 'move',
        boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
      }}
    >
      <h4 style={{ margin: '0 0 8px 0', fontSize: '14px' }}>{task.title}</h4>
      <p style={{ margin: 0, fontSize: '12px', color: '#666' }}>{task.description}</p>
      <div style={{ marginTop: '8px', fontSize: '11px', color: '#999' }}>
        {task.assignee}
      </div>
    </div>
  );
}

各タスクカードには、タイトル、説明、担当者が表示されます。 ドラッグ時にはcolumnIdも含めて、どのカラムから移動されたかがわかるようになっています。

次に、カラム(列)のコンポーネントを作ります。

function Column({ column, tasks, moveTask }) {
  const [{ isOver }, drop] = useDrop({
    accept: ItemTypes.TASK,
    drop: (item) => {
      if (item.columnId !== column.id) {
        moveTask(item.id, item.columnId, column.id);
      }
    },
    collect: (monitor) => ({
      isOver: monitor.isOver(),
    }),
  });

  return (
    <div
      ref={drop}
      style={{
        backgroundColor: isOver ? '#f0f8ff' : '#f8f9fa',
        border: '1px solid #dee2e6',
        borderRadius: '8px',
        padding: '16px',
        minHeight: '400px',
        width: '280px',
        margin: '0 16px',
      }}
    >
      <h3 style={{ 
        margin: '0 0 16px 0', 
        fontSize: '16px', 
        fontWeight: 'bold',
        color: '#495057'
      }}>
        {column.title}
        <span style={{ 
          backgroundColor: '#6c757d', 
          color: 'white', 
          borderRadius: '12px', 
          padding: '2px 8px', 
          fontSize: '11px',
          marginLeft: '8px'
        }}>
          {tasks.length}
        </span>
      </h3>
      
      {tasks.map((task, index) => (
        <TaskCard
          key={task.id}
          task={task}
          index={index}
          moveTask={moveTask}
        />
      ))}
    </div>
  );
}

カラムにタスクカードがドロップされると、moveTask関数が呼ばれます。 同じカラム内でのドロップは無視して、異なるカラム間の移動のみを処理します。

タスクの数がバッジで表示されるのも親切ですね。

ソート可能なリスト

アイテムの順序を変更できるリストを作ってみましょう。

// src/components/SortableList.js
import React, { useState, useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';

const ItemTypes = {
  LIST_ITEM: 'listItem',
};

ソート可能なアイテムは、ドラッグとドロップの両方に対応する必要があります。

function SortableItem({ item, index, moveItem }) {
  const ref = useRef(null);

  const [{ isDragging }, drag] = useDrag({
    type: ItemTypes.LIST_ITEM,
    item: { id: item.id, index },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  });

  const [, drop] = useDrop({
    accept: ItemTypes.LIST_ITEM,
    hover: (draggedItem) => {
      if (!ref.current) return;

      const dragIndex = draggedItem.index;
      const hoverIndex = index;

      if (dragIndex === hoverIndex) return;

      moveItem(dragIndex, hoverIndex);
      draggedItem.index = hoverIndex;
    },
  });

  drag(drop(ref));

drag(drop(ref))で、同じ要素をドラッグ可能かつドロップ可能にしています。 hoverイベントを使うことで、リアルタイムでアイテムの順序が変わります。

これにより、ドラッグ中に他のアイテムが移動する、滑らかなソート体験を提供できます。

ファイルアップロード

ドラッグ&ドロップでファイルをアップロードする機能を作りましょう。

// src/components/FileUpload.js
import React, { useState } from 'react';
import { useDrop } from 'react-dnd';
import { NativeTypes } from 'react-dnd-html5-backend';

React DnDでは、NativeTypes.FILEを使ってファイルのドラッグ&ドロップを扱えます。

export default function FileUpload() {
  const [uploadedFiles, setUploadedFiles] = useState([]);
  const [isUploading, setIsUploading] = useState(false);

  const [{ isOver, canDrop }, drop] = useDrop({
    accept: [NativeTypes.FILE],
    drop: (item) => {
      handleFileDrop(item.files);
    },
    collect: (monitor) => ({
      isOver: monitor.isOver(),
      canDrop: monitor.canDrop(),
    }),
  });

NativeTypes.FILEを受け入れることで、ファイルのドロップに対応します。 item.filesで、ドロップされたファイルのリストを取得できます。

ファイルの処理も実装しましょう。

  const handleFileDrop = async (files) => {
    setIsUploading(true);
    
    const newFiles = Array.from(files);
    
    // 重複チェック
    const uniqueFiles = newFiles.filter(newFile => 
      !uploadedFiles.some(existingFile => 
        existingFile.name === newFile.name && 
        existingFile.size === newFile.size
      )
    );

    if (uniqueFiles.length === 0) {
      alert('すべてのファイルは既にアップロード済みです');
      setIsUploading(false);
      return;
    }

    // アップロード処理をシミュレート
    await new Promise(resolve => setTimeout(resolve, 1000));
    
    setUploadedFiles(prev => [...prev, ...uniqueFiles]);
    setIsUploading(false);
  };

ファイル名とサイズで重複をチェックして、既存のファイルは除外します。 実際のプロジェクトでは、ここでサーバーへのアップロード処理を行います。

高度な機能とカスタマイズ

React DnDの応用的な機能を使って、より洗練されたUIを作ってみましょう。

カスタムドラッグプレビュー

ドラッグ時の見た目をカスタマイズできます。

// src/components/CustomPreview.js
import React from 'react';
import { useDrag } from 'react-dnd';
import { getEmptyImage } from 'react-dnd-html5-backend';

まず、カスタムプレビューコンポーネントを作ります。

function CustomDragPreview({ item }) {
  return (
    <div style={{
      backgroundColor: '#007bff',
      color: 'white',
      padding: '8px 12px',
      borderRadius: '4px',
      fontSize: '14px',
      fontWeight: 'bold',
      boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
      transform: 'rotate(-2deg)',
    }}>
      📦 {item.name} をドラッグ中
    </div>
  );
}

少し回転させることで、動的な印象を与えています。 色やシャドウも調整して、目立つデザインにしています。

次に、ドラッグ可能なアイテムでプレビューを設定します。

function DraggableWithPreview({ item }) {
  const [{ isDragging }, drag, preview] = useDrag({
    type: 'item',
    item: item,
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  });

  // 空の画像をプレビューに設定(デフォルトプレビューを無効化)
  React.useEffect(() => {
    preview(getEmptyImage(), { captureDraggingState: true });
  }, [preview]);

getEmptyImage()を使って、デフォルトのプレビューを無効化します。 そして、独自のプレビューコンポーネントを表示します。

複数タイプのドラッグ&ドロップ

異なる種類のアイテムを扱うシステムを作りましょう。

// src/components/MultiTypeDnD.js
import React, { useState } from 'react';
import { useDrag, useDrop } from 'react-dnd';

const ItemTypes = {
  TEXT: 'text',
  IMAGE: 'image',
  VIDEO: 'video',
  DOCUMENT: 'document',
};

アイテムタイプごとに異なるスタイルを適用します。

function TypedItem({ item, type }) {
  const [{ isDragging }, drag] = useDrag({
    type: type,
    item: { ...item, type },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  });

  const getStyle = () => {
    const baseStyle = {
      opacity: isDragging ? 0.5 : 1,
      border: '2px solid',
      borderRadius: '8px',
      padding: '12px',
      margin: '8px',
      cursor: 'move',
      display: 'inline-block',
      minWidth: '120px',
      textAlign: 'center',
    };

    switch (type) {
      case ItemTypes.TEXT:
        return { ...baseStyle, borderColor: '#28a745', backgroundColor: '#d4edda' };
      case ItemTypes.IMAGE:
        return { ...baseStyle, borderColor: '#17a2b8', backgroundColor: '#d1ecf1' };
      case ItemTypes.VIDEO:
        return { ...baseStyle, borderColor: '#ffc107', backgroundColor: '#fff3cd' };
      case ItemTypes.DOCUMENT:
        return { ...baseStyle, borderColor: '#dc3545', backgroundColor: '#f8d7da' };
      default:
        return baseStyle;
    }
  };

タイプに応じて色分けすることで、視覚的に分かりやすくしています。 緑はテキスト、青は画像、といった具合に直感的に理解できます。

セレクティブドロップゾーンも作りましょう。

function SelectiveDropZone({ acceptedTypes, title, onDrop, children }) {
  const [{ isOver, canDrop }, drop] = useDrop({
    accept: acceptedTypes,
    drop: (item) => {
      onDrop(item);
    },
    collect: (monitor) => ({
      isOver: monitor.isOver(),
      canDrop: monitor.canDrop(),
    }),
  });

  return (
    <div
      ref={drop}
      style={{
        backgroundColor: isOver && canDrop ? '#e8f5e8' : 
                        canDrop ? '#f8f9fa' : '#f5f5f5',
        border: `2px dashed ${isOver && canDrop ? '#28a745' : '#dee2e6'}`,
        borderRadius: '8px',
        padding: '20px',
        margin: '10px',
        minHeight: '200px',
      }}
    >
      <h3 style={{ marginTop: 0, textAlign: 'center' }}>{title}</h3>
      <div style={{ fontSize: '12px', textAlign: 'center', color: '#666', marginBottom: '16px' }}>
        受け入れ可能: {acceptedTypes.join(', ')}
      </div>
      {children}
    </div>
  );
}

acceptedTypes配列で、受け入れ可能なアイテムタイプを指定します。 対応していないアイテムをドラッグしても、視覚的に受け入れできないことがわかります。

パフォーマンスを向上させよう

大量のアイテムを扱う場合のパフォーマンス最適化テクニックです。

メモ化とコールバック最適化

不要な再レンダリングを防ぎます。

// src/components/OptimizedDnD.js
import React, { useState, useCallback, useMemo, memo } from 'react';
import { useDrag, useDrop } from 'react-dnd';

const DragItem = memo(({ item, onMove }) => {
  const [{ isDragging }, drag] = useDrag({
    type: 'item',
    item: { id: item.id, index: item.index },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  });

  const handleMove = useCallback((dragIndex, hoverIndex) => {
    onMove(dragIndex, hoverIndex);
  }, [onMove]);

  return (
    <div
      ref={drag}
      style={{
        opacity: isDragging ? 0.5 : 1,
        padding: '8px',
        margin: '4px',
        backgroundColor: '#f8f9fa',
        border: '1px solid #dee2e6',
        borderRadius: '4px',
        cursor: 'move',
      }}
    >
      {item.text}
    </div>
  );
});

memoでコンポーネントをメモ化し、useCallbackでコールバック関数をメモ化しています。 これにより、不要な再レンダリングを防げます。

メインコンポーネントでも最適化を行います。

export default function OptimizedDnD() {
  const [items, setItems] = useState(
    Array.from({ length: 1000 }, (_, index) => ({
      id: index,
      text: `アイテム ${index + 1}`,
      index,
    }))
  );

  const moveItem = useCallback((dragIndex, hoverIndex) => {
    setItems(prevItems => {
      const newItems = [...prevItems];
      const draggedItem = newItems[dragIndex];
      
      newItems.splice(dragIndex, 1);
      newItems.splice(hoverIndex, 0, draggedItem);
      
      return newItems.map((item, index) => ({
        ...item,
        index,
      }));
    });
  }, []);

  const visibleItems = useMemo(() => {
    return items.slice(0, 50); // 最初の50個のみ表示
  }, [items]);

1000個のアイテムから50個のみ表示することで、パフォーマンスを維持しています。 useMemoでvisibleItemsをメモ化することで、無駄な計算を防いでいます。

よくある問題と解決方法

React DnD使用時によく遭遇する問題を整理します。

DndProviderの設定忘れ

最もよくある間違いです。

// ❌ 間違った例
function App() {
  return (
    <div>
      <DragComponent />  // エラー: DndProvider外でuseDragを使用
    </div>
  );
}

// ✅ 正しい例
function App() {
  return (
    <DndProvider backend={HTML5Backend}>
      <div>
        <DragComponent />
      </div>
    </DndProvider>
  );
}

必ずDndProviderでアプリをラップしましょう。

refの間違った使用

複数のrefを正しく結合する必要があります。

// ❌ 間違った例
function DragComponent() {
  const [, drag] = useDrag({/* ... */});
  return <div ref={drag} />; // 複数のrefを正しく結合していない
}

// ✅ 正しい例
function DragComponent() {
  const ref = useRef(null);
  const [, drag] = useDrag({/* ... */});
  const [, drop] = useDrop({/* ... */});
  
  drag(drop(ref)); // refを正しく結合
  
  return <div ref={ref} />;
}

drag(drop(ref))の形で、refを結合します。

アイテムタイプの不一致

ドラッグ側とドロップ側でタイプが一致していないとうまく動きません。

// ✅ 正しい例
const ItemTypes = {
  ITEM: 'item',
};

const [, drop] = useDrop({
  accept: ItemTypes.ITEM,
  drop: (item) => {/* ... */}
});

const [, drag] = useDrag({
  type: ItemTypes.ITEM,
  item: {/* ... */}
});

定数を定義して、タイプミスを防ぎましょう。

まとめ:直感的なUIを作ろう!

React DnDを使うことで、ユーザーにとって直感的で使いやすいインターフェースを作れます。

React DnDの主なメリット

  • 直感的な操作: ドラッグ&ドロップで自然な操作感を提供
  • 高度なカスタマイズ: 複雑な要件にも柔軟に対応
  • クロスブラウザ対応: HTML5 APIの互換性問題を解決
  • モバイル対応: タッチデバイスでもスムーズに動作

実装時のポイント

  1. 適切なプロバイダー設定: デスクトップとモバイルで使い分け
  2. アイテムタイプの管理: 定数で一元管理して間違いを防ぐ
  3. パフォーマンス最適化: メモ化や仮想スクロールを活用
  4. 型安全性: TypeScriptで安全な実装

活用できる場面

  • タスク管理: カンバンボードやプロジェクト管理
  • ファイル操作: ファイルアップロードや整理機能
  • コンテンツ編集: ページビルダーやレイアウト編集
  • データ可視化: インタラクティブなダッシュボード

学習の進め方

// 1. 基本概念の理解
import { useDrag, useDrop } from 'react-dnd';

// 2. 基本的なドラッグ&ドロップ
const [{ isDragging }, drag] = useDrag({
  type: 'item',
  item: { id: 1 },
});

// 3. 実用的なUIパターンの実装
// カンバンボード、ソート可能リスト、ファイルアップロード

// 4. パフォーマンス最適化
// メモ化、仮想スクロール、遅延読み込み

// 5. 高度な機能
// カスタムプレビュー、マルチタイプ、条件付きドロップ

React DnDは学習コストはありますが、一度覚えてしまえば様々なインタラクティブUIを効率的に実装できます。

ぜひ今回紹介した実装例を参考にして、あなたのプロジェクトでドラッグ&ドロップ機能を活用してみてください。 きっと、ユーザーに喜ばれる直感的なUIが作れますよ!

関連記事