kurosame’s diary

フロントエンド中心です

React DnD v11(古いバージョン)の実装

はじめに

少し癖のあるReact DnDを古いバージョンで使わなければならない案件を対応しました
3 つくらい古いメジャーバージョンだったので、公式ドキュメントの Examples が動かず、かつ、ネット上にドキュメントが少なかったので、一応実装をこの記事に残しておきます

この記事の React DnD のバージョンは以下です

"react-dnd": "11.1.3",
"react-dnd-html5-backend": "11.1.3",

ちなみに現在の最新は v14 です
v11 でも hooks に対応されていた分、まだ楽な方だったかなと思います

実装内容は以下の Example のような D&D(ドラッグ&ドロップ)による要素の入れ替えです
https://react-dnd.github.io/react-dnd/examples/sortable/simple

Example では要素をいい感じに並び替えていますが、今回は実装を簡単にするため swap(入れ替え)するだけにしました

ルートコンポーネントの設定

ルートコンポーネントで以下を設定

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

<DndProvider backend={HTML5Backend}>
  <Example />
</DndProvider>;

コンポーネントの実装

コンポーネントでは、State と D&D された時に要素を入れ替えるロジック(moveBox)を持っています
moveBox は fromId(ドラッグしたアイテムの ID)と toId(ドロップされたアイテムの ID)を引数で渡して、場所を入れ替えているだけです

import { useState, useCallback } from "react";
import produce from "immer";

const [items, setItems] = useState([
  { id: 1, text: "アイテム1" },
  { id: 2, text: "アイテム2" },
  { id: 3, text: "アイテム3" },
]);

const findIdx = useCallback(
  (id: number) => items.findIndex((c) => c.id === id),
  [items]
);
const moveBox = useCallback(
  (fromId: number, toId: number) => {
    const from = findIdx(fromId);
    const to = findIdx(toId);
    const swapItems = produce(items, (c) => {
      [c[from], c[to]] = [c[to], c[from]];
      return c;
    });
    setItems(swapItems);
  },
  [items]
);

const DND_TYPE = "items_dnd";

return (
  <>
    {items.map((c) => (
      <BoxDnD key={c.id} id={c.id} type={DND_TYPE} moveBox={moveBox}>
        <Example />
      </BoxDnD>
    ))}
  </>
);

後述する<BoxDnD />が View の部分ですが、要素を入れ替えるロジックは別で切り離しておいた方が良いでしょう
D&D ライブラリを別のものに替えても使えると思うので

コンポーネントの実装

useDrag と useDrop という 2 つの hooks を駆使して実装します

interface BoxDndProps {
  id: number;
  type: string;
  moveBox(fromId: number, toId: number): void;
  children: React.ReactNode;
}

export const BoxDnD = ({ id, type, moveBox, children }: BoxDndProps) => {
  type ItemType = {
    type: typeof type;
    id: number;
  };

  const [{ isDragging }, drag] = useDrag<
    ItemType,
    unknown,
    { isDragging: boolean }
  >({
    item: { type, id },
    collect: (m) => ({ isDragging: m.isDragging() }),
  });
  const [, drop] = useDrop({
    accept: type,
    drop({ id: fromId }: ItemType) {
      if (fromId !== id) moveBox(fromId, id);
    },
  });

  return (
    // Boxの実装は省略
    <Box ref={(node) => drag(drop(node))} isDragging={isDragging}>
      {children}
    </Box>
  );
};

useDrag

v14 だとコールバック関数になってますが、v11 だとただの関数ですね

useDrag のパラメーターの item は識別子となるプロパティです
type はドロップターゲットを特定するために必要で、上記実装では string 型にしていますが Symbol 型の方が良さそうですね
id はアイテム要素を特定するために一意の識別子を設定します
今回はアイテムの ID を渡してます

useDrag のパラメーターの collect はコールバック関数になっていて、ランタイムで実際にドラッグした時、引数の m(monitor)にドラッグの状態が入ってきます
上記の実装では、ドラッグされているかの boolean を返す isDragging を返しています

useDrag の戻り値は 3 つの要素を持つタプルで返ってきます
タプルの 1 つ目は、collect で収集したオブジェクトを受け取ります
collect 関数を定義しなかった場合、空オブジェクトを返します
タプルの 2 つ目は、drag の ref を返します
これをドラッグする DOM にアタッチします
タプルの 3 つ目は、drag の preview の ref を返します
今回は使用しませんが、ドラッグしている時は別の DOM へアタッチしたい場合に使用します

useDrop

こちらも v14 だとコールバック関数になってますが、v11 だとただの関数です

useDrop のパラメーターの accept はドロップターゲットの識別子です

useDrop のパラメーターの drop はドロップされた時に呼び出される関数です
drop の第 1 引数にドラッグされている item を保持しています
上記実装では、ドロップされた時に moveBox を呼び出しています

useDrop の戻り値は 2 つの要素を持つタプルで返ってきます
タプルの 1 つ目は、collect で収集したオブジェクトを受け取ります
今回は使用していません
タプルの 2 つ目は、drop の ref を返します
これをドロップターゲットの DOM にアタッチします

ドラッグされる DOM とドロップされる DOM が同じ場合

今回だと Box コンポーネントですが、以下の書き方でいけます
drag(drop(ref))

[追記 1/24] swapではなく、並び替えにした場合の実装

// 親コンポーネント
const findItem = useCallback(
  (id: number) => {
    const idx = items.findIndex(c => c.id === id);
    return { idx, item: items[idx] };
  },
  [items]
);
const moveBox = useCallback(
  (fromId: number, toId: number) => {
    const { idx: fromIdx, item } = findItem(fromId);
    const { idx: toIdx } = findItem(toId);
    const sortItems = produce(items, c => {
      c.splice(fromIdx, 1);
      c.splice(toIdx, 0, item);
      return c;
    });
    setItems(sortItems);
  },
  [items]
);

// 子コンポーネント
const [, drop] = useDrop({
  accept: DND_TYPE,
  hover({ id: fromId }: ItemType) {
    if (fromId !== id) moveBox(fromId, id);
  },
});