ドラッグ&ドロップできるリストコンポーネントを作成する React 用ライブラリの dnd kit を使ってみた

こんにちは、Gaji-Labo アシスタントエンジニアの石垣です。

今回は、ドラッグ&ドロップが可能なコンポーネントを作成する React 用ライブラリの dnd kit を触る機会があったため、使用方法についてまとめてみます。

dnd kit について

dnd kitの公式サイトのキャプチャ画像

dnd kit は React 用の、ドラック&ドロップ可能なコンポーネントを実装するためのライブラリです。

公式ドキュメントによると軽量でパフォーマンスが高く、拡張可能であり、かつアクセシビリティも担保していることを売りとしています。GitHub でも最新バージョンが一ヶ月前に公開されているため、開発も活発であることが窺えます。

実装する上での観点としては、ドラッグ&ドロップをコンポーネントではなく用意されている hooks で実装するのが大きな特徴です。

公式で Storybook が公開されており、縦並びのリストや横並びのリスト、グリッドレイアウトのリスト等様々なユースケースに対応する実装を見ることができます。

個人的にはサンプルで2Dゲームを実装しているのが目を惹きました。

Storybook上でチェッカーが実際にプレイできる

dnd kit で簡単なドラッグ&ドロップが可能なリストコンポーネントを作成する

導入

まずは dnd kit をプロジェクトに追加します。

yarn add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

@dnd-kit/core がコアライブラリであり、これだけでもドラッグ&ドロップ可能なコンポーネントを作成することができますが、 @dnd-kit/sortable を導入することでより簡単に実装しやすくなる hooks を使用することができます。

@dnd-kit/utilities も実装の上で便利なユーティリティを使うことができるようになるため導入します。詳しくは後述します。

コンポーネント実装

並び替え用に8つのカードを持つ配列を作成しました。これを並び替えるコンポーネントを作成してみます。

角丸のカードが8つ横に並んでいる様子のキャプチャ
interface draggableCardProps {
  children: string;
}

export function DraggableCard({ children }: draggableCardProps) {
  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        margin: 4,
        borderRadius: 4,
        width: "150px",
        height: "150px",
        border: "1px solid black",
        backgroundColor: "white",
      }}
    >
      {children}
    </div>
  );
}

const items = ["1", "2", "3", "4", "5", "6", "7", "8"];
const contents = items.map((item) => ({
  id: item,
  content: <DraggableCard>{item.toString()}</DraggableCard>,
}));

上のカードを並び替えるためのコンポーネントの実装がこちらになります。以下、こちらを参考に解説していきます。

import { DndContext } from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

...

interface SortableItemProps {
  id: string;
  children: ReactElement;
}

function SortableItem({ id, children }: SortableItemProps) {
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({ id });
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };
  return (
    // eslint-disable-next-line react/jsx-props-no-spreading
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      {children}
    </div>
  );
}

export const DndSample = (): JSX.Element => {
  const [state, setState] =
    useState<{ id: string; content: ReactElement }[]>(contents);
  const handleDragEnd = useCallback(
    (event) => {
      const { active, over } = event;
      if (over === null) {
        return;
      }
      if (active.id !== over.id) {
        const oldIndex = state
          .map((item) => {
            return item.id;
          })
          .indexOf(active.id);
        const newIndex = state
          .map((item) => {
            return item.id;
          })
          .indexOf(over.id);
        const newState = arrayMove(state, oldIndex, newIndex);
        setState(newState);
      }
    },
    [state]
  );

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <SortableContext items={state}>
        <div style={{ display: "flex", flexWrap: "wrap" }}> // スタイル調整用
          {state.map((item) => (
            <SortableItem key={item.id} id={item.id}>
              {item.content}
            </SortableItem>
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
};

1. 移動用のコンポーネントを作成する

まずは先程のカードをラップする移動用のコンポーネントを作成します。

import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

...

interface SortableItemProps {
  id: string;
  children: ReactElement;
}

function SortableItem({ id, children }: SortableItemProps) {
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({ id });
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };
  return (
    // eslint-disable-next-line react/jsx-props-no-spreading
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      {children}
    </div>
  );
}

前述の通り、 useSortable という hooks を @dnd-kit/sortable から呼び出して使っています。

useSortable はドラッグ&ドロップの実装に必要ないくつかのプロパティを返しますが、ここでは5つのプロパティを使っています。この5つは全て必須です。

この中で attributes listeners setNodeRef はドラッグ&ドロップさせるコンポーネントに直接渡します。

残りの transform transition はドラッグ&ドロップの移動とアニメーションをCSSで行うためのプロパティです。こちらも style として渡さないとコンポーネントを移動させることができないため要注意です。

transform に値を渡すにあたって @dnd-kit/utilities から呼び出した CSS を使っています。これは transform の値は object で返ってくるため、object から string に変換する手間を省くために用意されているユーティリティです。

これで移動用のコンポーネントを作成することができました。

2. コンポーネントをドラッグ&ドロップ可能にする

import { DndContext } from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";

export const DndSample = (): JSX.Element => {
  const [state, setState] =
    useState<{ id: string; content: ReactElement }[]>(contents);
  const handleDragEnd = useCallback(
    ...
  );

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <SortableContext items={state}>
        <div style={{ display: "flex", flexWrap: "wrap" }}> // スタイル調整用
          {state.map((item) => (
            <SortableItem key={item.id} id={item.id}>
              {item.content}
            </SortableItem>
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
};

コンポーネントをドラッグ&ドロップ可能にするために、DndContext と SortableContext というコンポーネントを呼び出して使用します。

この2つを上述した移動用のコンポーネントをそのままラップし、SortableContext に配列を渡すだけでドラッグ&ドロップができるようになります。この辺りはすっきりしていて書きやすいと思いました。

3. ドラッグ&ドロップ後に配列の状態を保存する

import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";

...

export const DndSample = (): JSX.Element => {
  const [state, setState] =
    useState<{ id: string; content: ReactElement }[]>(contents);
  const handleDragEnd = useCallback(
    (event) => {
      const { active, over } = event;
      if (over === null) {
        return;
      }
      if (active.id !== over.id) {
        const oldIndex = state
          .map((item) => {
            return item.id;
          })
          .indexOf(active.id);
        const newIndex = state
          .map((item) => {
            return item.id;
          })
          .indexOf(over.id);
        const newState = arrayMove(state, oldIndex, newIndex);
        setState(newState);
      }
    },
    [state]
  );

  return (
    <DndContext onDragEnd={handleDragEnd}>
      ...
    </DndContext>
  );
};
...

最後に handleDragEnd でドラッグ&ドロップ後に配列の状態を保存できるようにします。

DndContext は event という引数を取り、これは activeover の2つの値を取ります。active は動かしたコンポーネントの移動開始時の状態で、 over は移動終了時の状態を取ります。もしコンポーネントをドラッグ&ドロップ可能領域の外に出した場合は over は null を返します。

また、配列操作を簡単にするためのユーティリティとして arrayMove という関数が用意されています。

これでドラッグ&ドロップが可能なリストコンポーネントを作成することができました。

ドラッグ&ドロップした後に状態が保存されている

まとめ

今回はドラッグ&ドロップが可能なコンポーネントを作成する React 用ライブラリの dnd kit について使用方法をまとめました。

dnd kit は hooks を使って実装するという点が React を書き慣れている人には直感的で実装しやすいと感じました。

簡単に実装できる割に拡張性も高そうなので、今後も詳しい使い方を触りながら学んでいきたいと思っています。

Gaji-Laboでは、React経験が豊富なフロントエンドエンジニアを募集しています

弊社ではReactの知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違うGaji-Laboを味わいに来ませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!

投稿者 Ishigaki Shotaro

アシスタントエンジニアとしてHTML/CSS/JavaScriptの実装やRailsの組み込み、スタイルガイドの構築などを担当しています。 業務の中でさまざまな学びを吸収しながら、文書構造やアクセシビリティに目を向けたマークアップの学習やJavaScriptの学習などを行っています。チームに貢献できるエンジニアとなるために日々奮闘中です。