ライブラリ無しでReact によるドラッグアンドドロップ並べ替えリスト作成

## これを作るよ！！

![gifアニメ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187949/5de0be78-5cfe-43e0-bd56-1ce25b5eb2dd.gif)

React 製のドラッグアンドドロップによる並べ替え可能なリストの作成を行います。
React対応のドラッグアンドドロップによる並べ替えを可能にするライブラリは
[React DnD](https://react-dnd.github.io/react-dnd/about)
[dnd kit ](https://dndkit.com/)
[react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd)
等がありますが、基本的に欲しいものは単純なドラッグアンドドロップによる並べ替え機能だけなので、今回はこれらのライブラリ無しで行います。
せっかくなので、プレーンなReactプロジェクトを作ってその上に構築していきます。

## React 構築

nodeがすでにインストールされている前提で行います。以下のコマンドを実行。
### create-react-app の実行
`npx create-react-app drag_and_drop_sample --template typescript`

### ディレクトリ移動
`cd drag_and_drop_sample`

### 起動コマンド実行
`npm start`
react アプリが立ち上がりました。

![Pasted image 20250502215310.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187949/fdee888a-3eb0-4df5-8b7f-17bc8e94379b.png)

## コンポーネントの追加、余分箇所の除去

src 配下に以下のファイルを作成

```tsx:DraggableBox.tsx
type DraggableBoxProps = {
  title: string;
  id: number;
  onDragStart: React.DragEventHandler;
  onDragEnter: React.DragEventHandler;
  onDragEnd: () => void;
}
const DraggableBox = (props: DraggableBoxProps) => {
  const {title, id, onDragStart, onDragEnter,onDragEnd} = props;

  return (
  <div 
    style={{
      width: "200px", 
      height: "100px", 
      border: "1px solid black"
    }}
    onDragStart={onDragStart}
    onDragEnter={onDragEnter}
    onDragEnd={onDragEnd}
    draggable={true}
    >
    <p>{title}</p>
    <p>{id}</p>
  </div>
  );
};

export default DraggableBox;
```

`draggable={true}` をつけると要素をドラッグアンドドロップ可能な要素として設定ができます。

[HTMLElement: draggable プロパティ - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/draggable)

[ドラッグ操作 - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations)

onDragStart, onDragEnter, onDragEnd は HTML ドラッグ & ドロップ API イベントのためのイベントハンドラタイプです。

[\<div\> などの一般的なコンポーネント – React](https://ja.react.dev/reference/react-dom/components/common)
[### `DragEvent` ハンドラ関数](https://ja.react.dev/reference/react-dom/components/common#dragevent-handler)


ページファイルを以下に修正（初期要素除去、子コンポーネントのimport、データ、関数の雛形作成）

```diff:App.tsx
-import React from 'react';
-import logo from './logo.svg';
+import DraggableBox from './DraggableBox';
+import { useRef, useState } from 'react';
import './App.css';

function App() {
+  const dragItem = useRef<number | null>(null);
+  const dragOverItem = useRef<number | null>(null);
  
+  const [data,setData] = useState([
+    {
+      id: 1,
+      title: "apple",
+    },
+    {
+      id: 2,
+      title: "banana",
+    },
+    {
+      id: 3,
+      title: "orange",
+    },
+    {
+      id: 4,
+      title: "grape",
+    },
+    {
+      id: 5,
+      title: "kiwi",
+    }
+]);
+
+const drop = () => {
+}
+
+const dragStart = (index:number) => {
+}
+
+const dragEnter = (index:number) => {
+}
  return (
    <div className="App">
-      <header className="App-header">
-        <img src={logo} className="App-logo" alt="logo" />
-        <p>
-          Edit <code>src/App.tsx</code> and save to reload.
-        </p>
-        <a
-          className="App-link"
-          href="https://reactjs.org"
-          target="_blank"
-          rel="noopener noreferrer"
-        >
-          Learn React
-        </a>
-      </header>
+      {data.map((datum, index) => (
+        <DraggableBox title={datum.title} id ={datum.id} key = {index}
+          onDragStart={() => dragStart(index)}
+          onDragEnter={() => dragEnter(index)}
+          onDragEnd={drop}
+        />
+      ))}
    </div>
  );
}

export default App;
```

現時点で以下のようになっています。

![Pasted image 20250503195053.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187949/799da142-f4e8-423b-affe-779b56161bec.png)

## 関数の導入
関数を修正します。
以下のようなドラッグアンドドロップで並び替える機構が完成します。

![gifアニメ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187949/5de0be78-5cfe-43e0-bd56-1ce25b5eb2dd.gif)

完成したコードは以下、
```diff:App.tsx
import DraggableBox from './DraggableBox';
import { useRef, useState } from 'react';
import './App.css';

function App() {
  const dragItem = useRef<number | null>(null);
  const dragOverItem = useRef<number | null>(null);
  
  const [data,setData] = useState([
    {
      id: 1,
      title: "apple",
    },
    {
      id: 2,
      title: "banana",
    },
    {
      id: 3,
      title: "orange",
    },
    {
      id: 4,
      title: "grape",
    },
    {
      id: 5,
      title: "kiwi",
    }
]);

const drop = () => {
+  const copyListItems = [...data];
+  if (dragItem.current !== null && dragOverItem.current !== null) {
+    const dragItemContent = copyListItems[dragItem.current];
+    copyListItems.splice(dragItem.current, 1);
+    copyListItems.splice(dragOverItem.current, 0, dragItemContent);
+  }
+  dragItem.current = null;
+  dragOverItem.current = null;
+  setData(copyListItems)
}

const dragStart = (index:number) => {
+  dragItem.current = index;
}

const dragEnter = (index:number) => {
+  dragOverItem.current = index;
}

  return (
    <div className="App">
      {data.map((datum, index) => (
        <DraggableBox title={datum.title} id ={datum.id} key = {index}
          onDragStart={() => dragStart(index)}
          onDragEnter={() => dragEnter(index)}
          onDragEnd={drop}
        />
      ))}
    </div>
  );
}

export default App;
```

コメントを全行につけるとこんなところでしょうか？

```tsx:App.tsx
import DraggableBox from './DraggableBox';
import { useRef, useState } from 'react';
import './App.css';

function App() {
  // ドラッグ開始対象要素の参照オブジェクト
  const dragItem = useRef<number | null>(null);

  // ドラッグが重なっている対象要素の参照オブジェクト
  const dragOverItem = useRef<number | null>(null);

  // ドラッグ対象のデータ
  const [data,setData] = useState([
    {
      id: 1,
      title: "apple",
    },
    {
      id: 2,
      title: "banana",
    },
    {
      id: 3,
      title: "orange",
    },
    {
      id: 4,
      title: "grape",
    },
    {
      id: 5,
      title: "kiwi",
    }
]);

// ドラッグ終了時の処理
const drop = () => {
  // ドラッグ対象のデータをコピー
  const copyListItems = [...data];
  // ドラッグ対象のデータがnullでない場合
  if (dragItem.current !== null && dragOverItem.current !== null) {
    // ドラッグ対象のデータを取得
    const dragItemContent = copyListItems[dragItem.current];
    // ドラッグ対象のデータを削除
    copyListItems.splice(dragItem.current, 1);
    // ドラッグ対象のデータをドラッグが重なっている対象の位置に挿入
    copyListItems.splice(dragOverItem.current, 0, dragItemContent);
  }
  // ドラッグ開始対象要素の参照オブジェクトをnullにする
  dragItem.current = null;
  // ドラッグが重なっている対象要素の参照オブジェクトをnullにする
  dragOverItem.current = null;
  // ドラッグ対象のデータを更新
  setData(copyListItems)
}

// ドラッグ対象のデータのインデックスを取得
const dragStart = (index:number) => {
  dragItem.current = index;
}

// ドラッグが重なっている対象のデータのインデックスを取得
const dragEnter = (index:number) => {
  dragOverItem.current = index;
}

  return (
    <div className="App">
      {data.map((datum, index) => (
        <DraggableBox title={datum.title} id ={datum.id} key = {index}
          onDragStart={() => dragStart(index)}
          onDragEnter={() => dragEnter(index)}
          onDragEnd={drop}
        />
      ))}
    </div>
  );
}

export default App;
```

コメントをつけてもわかりにくいですね。

## コード理解のポイント
### ポイント1 関数の発火のタイミング

`drop`, 
`dragStart`,
`dragEnter`,

のそれぞれ関数の発火のタイミングを見てみましょう。 

関数にconsole.logを仕込み、動作させてみます。
```tsx

		// イベントハンドラの登録箇所
		// onDragStart, onDragEnter, onDragEnd
        <DraggableUi title={datum.title} id ={datum.id} key = {index}
          onDragStart={() => dragStart(index)}
          onDragEnter={() => dragEnter(index)}
          onDragEnd={drop}
        />

// それぞれconsole.log 記述箇所、抜粋
// ドラッグ終了時の処理
const drop = () => {
  console.log("drop発火");
  
// ドラッグ対象のデータのインデックスを取得
const dragStart = (index:number) => {
  console.log("dragStart発火");
  
// ドラッグが重なっている対象のデータのインデックスを取得
const dragEnter = (index:number) => {
  console.log("dragEnter発火");
```

![gifアニメ2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187949/36047047-35a1-461c-9b75-ae8378c4b740.gif)

タイミング的には以下で発火していることがわかります。
#### onDragStart
- **発火タイミング**: 要素のドラッグが開始された瞬間
- **何をしている?**: dragStartの発火 → 果物のアイテム要素のインデックスを dragItem に保持
#### onDragEnter
- **発火タイミング**: ドラッグ中の要素が、別の要素の領域に入った時
- **何をしている?**: dragEnterの発火 → ドラッグが重なっている果物要素のインデックスを dragOverItem に保持
#### onDragEnd
- **発火タイミング**: ドラッグ操作が完了した時

- **何をしている?**: drop 処理の発火 → (処理詳細は後述)

補足として、より完全なドラッグ＆ドロップの実装には以下のイベントも有用です：
- `onDragOver`: ドラッグ中の要素が別の要素の上にある間、継続的に発火
- `onDragLeave`: ドラッグ中の要素が別の要素の領域から出た時に発火
- `onDrop`: 要素がドロップされた時に発火

### ポイント2 ドラッグ操作完了時は何をしている？

`dragStart`,
`dragEnter`,

はなんだか大した事なさそうです。
`drop` 関数を追いかけて見ましょう。
#### drop
2 番目の果物を 4番目に引っ張ってきたときの処理イメージを上から順に見てみましょう。

```tsx:drop 処理始め
// ドラッグ終了時の処理
const drop = () => {
  // ドラッグ対象のデータをコピー
  const copyListItems = [...data];
```
特に難しいことはありません。
コピーしているだけです

![Pasted image 20250503204008.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187949/364fc66a-65ba-430c-a7e9-c6c71384ccf5.png)

```tsx:drop 処理序盤
  // ドラッグ対象のデータがnullでない場合
  if (dragItem.current !== null && dragOverItem.current !== null) {
    // ドラッグ対象のデータを取得
    const dragItemContent = copyListItems[dragItem.current];
    // ドラッグ対象のデータを削除
    copyListItems.splice(dragItem.current, 1);
```
if 文は単なるnullチェックで、
dragItemContent に 2 番目の果物(ドラッグ対象のデータ) を添え字の`dragItem.current` インデックスから参照し保持します。
次に、copyListItem から 2 番目の果物(ドラッグ対象のデータ)を削除しています。
※こちらもdragItem.current インデックスから位置を参照しています。

![Pasted image 20250503204801.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187949/af55a047-7af9-4ec8-868c-5aaddcb372bd.png)

```tsx:drop 処理中盤
    // ドラッグ対象のデータをドラッグが重なっている対象の位置に挿入
    copyListItems.splice(dragOverItem.current, 0, dragItemContent);
```

copyListItems から、ドラッグが重なっている対象の位置を割り出し(dragOverItem.current のindex位置)
dragItemContent に保持していた 2 番目の果物(ドラッグ対象のデータ) を置き換えます。

![Pasted image 20250503205255.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187949/a1e5604d-c6fe-4620-8dd9-b2443b58d67d.png)

```tsx:drop 処理終盤
  // ドラッグ開始対象要素の参照オブジェクトをnullにする
  dragItem.current = null;
  // ドラッグが重なっている対象要素の参照オブジェクトをnullにする
  dragOverItem.current = null;
  // ドラッグ対象のデータを更新
  setData(copyListItems)
```

`dragItem.current = null;` `dragOverItem.current = null;`
部分で参照オブジェクトをそれぞれリセットし
`setData(copyListItems)` で copyListItem から 画面の描画リストの元となる data に 入れ直し
画面が再レンダリングされます。

![Pasted image 20250503205752.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187949/c1fac901-5684-4065-9b69-c60a6f091420.png)

data の通りに並び替えが行われます。
## 例 MUI を導入した例

[MUI: The React component library you always wanted](https://mui.com/)

UIフレームワークのMUI で 要素を作ってみることも可能です。
以下のコマンドを実行しMUI をインストール

```
npm install @mui/material @emotion/react @emotion/styled
```

DraggableBox を以下に変更

```tsx
import Stack from '@mui/material/Stack';

type DraggableUiProps = {
  title: string;
  id: number;
  onDragStart: React.DragEventHandler;
  onDragEnter: React.DragEventHandler;
  onDragEnd: () => void;
}

const DraggableBox = (props: DraggableUiProps) => {
  const {title, id, onDragStart, onDragEnter,onDragEnd} = props;
  return (
  <Stack 
    spacing={2} 
    sx={{ 
      width:'200px', 
      height:'100px', 
      border: '1px solid black' 
    }}
    onDragStart={onDragStart}
    onDragEnter={onDragEnter}
    onDragEnd={onDragEnd}
    draggable={true}
    >
    <h1>{title}</h1>
    <h2>{id}</h2>
  </Stack>
  );
};

export default DraggableBox;
```

## まとめ
作ってみると stateとprops を更新することによって画面の描画を行う、実にReactらしい機構になりました。

ライブラリを使っていない分、若干複雑になりましたが、
以前ライブラリをつかって実装してみたこともあり、ある程度ライブラリが隠蔽してくれる部分はあるのですが
例えば以下の発火タイミング
- onDragStart:要素のドラッグが開始された瞬間
- onDragEnter:ドラッグ中の要素が、別の要素の領域に入った時
- onDragEnd:ドラッグ操作が完了した時
- onDragOver:ドラッグ中の要素が別の要素の上にある間、継続的に発火
- onDragLeave:ドラッグ中の要素が別の要素の領域から出た時に発火
- onDrop:要素がドロップされた時に発火

これらを意識した実装にせざる負えないので「ライブラリを使っても普通に難しかった」というのが感想です。
必要最低限の機能でいいのであれば、ライブラリ無しで作ってしまうのも候補として有りだと思います。