STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

React DnDを使ったので知見をまとめた

始めに

STORES 予約でエンジニアをしているTak-Iwamoto です。

今回はある項目の並び替え機能を実装する際に React DnD を使用したので、その知見について書かせていただきます。 実装した画面はこんな感じです。 f:id:Tak-Iwamoto:20210305130258g:plain

ライブラリ

STORES 予約の管理画面は Rails の slim -> React へのリプレイスが進行中です。

なので React のドラッグ&ドロップライブラリを検討しました。

star 数の多いライブラリは以下のものがあります。

React-Draggable は最終リリース ver が 2016/12 なので対象外として、React DnD と react-beautfiul-dnd のどちらかが候補となります。

あくまで自分の考えではありますが、それぞれの pros, cons を列挙してみました。

React DnD

pros

  • 継続的にメンテされている(2021/03/05 時点で最新 ver は 2021/02/24 にリリース)
  • TypeScript で実装されているので、Definitely Typedが必要ない
  • Hooks API がある

cons

  • 学習コストが高め(な気がする)
  • 提供される機能が react-beautiful-dnd と比較すると少ない

API が洗練されているが、少し学習コストが高い印象です。

react-beautiful-dnd

pros

cons

  • React DnD と比較すると活発にメンテされていない(2021/03/05 時点で最新 ver は約 1 年前のリリース)
  • Definitely Typed が必要
  • React DnD が html 5 のドラッグ&ドロップ機能を使って実装されているが、react-beautiful-dnd はそうではない (react-beautiful-dnd の README では皮肉たっぷりな感じで html5 の drag and drop を dis っているが...)

    There are a lot of libraries out there that allow for drag and drop interactions within React. Most notable of these is the amazing react-dnd. It does an incredible job at providing a great set of drag and drop primitives which work especially well with the wildly inconsistent html5 drag and drop feature.

大手企業のAtlassianが作っているので doc は充実しているが、最近メンテされていないのが気になるといった感じです。

今回は以上のことを踏まえて React DnD を採用しました。複雑な機能が必要でなかったことや継続的にメンテされている安心感が決め手です。

React DnD 解説

前提

前提として React DnDドラッグ&ドロップの見た目に関する機能は一切提供していません。

(react-beautiful-dnd は垂直方向か並行方向か選べたりなど、見た目に関する API もある)

React DnDview ではなく data を管理します。

いわば、ドラッグ&ドロップにまつわる状態管理のライブラリが React DnD です。

ドラッグ&ドロップの状態管理を行う上で React DnD で使用されている概念について説明していきます。

Item

React DnD はドラッグやドロップの動作が発生したとき、DOM やコンポーネントではなく、JavaScript の Object で表現された Item が移動していると認識します。 この Item はtypeというプロパティが必須です。type は一意の文字列で、これによりドラッグされるコンポーネントとそのドロップ先のコンポーネントを一意に紐づけています。 Redux に馴染みのある方は Redux の Action Type に相当する物が Item の type と考えてもらって問題ありません。

Monitor

ドラッグ&ドロップ中の状態をモニターするためのオブジェクトです。

Collect

Monitor を引数に取ってドラッグ&ドロップ中の状態を collect 関数で取り出します。

例えば、以下の例ではドラッグ中のコンポーネントがドロップ可能な時に highlightedにするためのオブジェクトを返しています。

function collect(monitor) {
  return (
    highlighted: monitor.canDrop(),
  )
}

一通り React DnD の概念的な部分を説明したので、次はドラッグとドロップに関する API を見ていきます。

API

useDrag

ドラッグするコンポーネントでこの hooks を使います。

export const Draggable: VFC<Props> = () => {
  const [collected, drag, dragPreview] = useDrag({
    item: {
      type: 'Item',
    },
    collect: (monitor) => {
      return { canDrag: monitor.canDrag, highlighted: monitor.isDragging };
    },
  });
  return <div ref={drag}>...</div>;
};

戻り値

配列を返します。それぞれの要素は以下の通りです。

  • index 0(例の collected): collect 関数で返されたオブジェクト。 つまり上の例では以下のオブジェクトになる。
const collected = { canDrag: monitor.canDrag, highlighted: monitor.isDragging };
  • index 1(例の drag): ドラッグ対象の ref に渡す。
  • index 2(例の dragPreview): ドラッグ中の preview で表示する DOM の ref に渡す。

引数

itemtype が必須でそれ以外は optional です。 先ほどの React DnD の概念でも説明しましたが、この type は全てのコンポーネントのなかで一意の文字列である必要があります。 ドラッグ&ドロップが大量に発生するのであれば、Redux の Action Type のように列挙しておくと便利です。

export const DnDItems = {
  Item: 'Item',
} as const;

export type DnDItems = typeof DnDItems[keyof typeof DnDItems];

他にも API があるので、doc を確認してみてください。

useDrop

ドロップするコンポーネントでこのhooksを使います。

export const DropTarget: VFC<Props> = () => {
  const [collected, drop] = useDrop({
    accept: 'Item',
  });
  return <div ref={drop}>...</div>;
};

戻り値

  • index 0(例の collected): useDrag と同様に collect 関数で返された Object。
  • index 1(例の drop): ドロップ対象の ref に渡す。

引数

acceptが必須でそれ以外は optional です。

このacceptに useDrag の引数のitem.typeを指定することでドラッグしたコンポーネントをドロップできます。

実践

では実際にドラッグ&ドロップを実装します。

サンプルコードはこちら

実行環境

  • Next.js 10.0.7
  • React 16.12.0
  • React DnD 13.1.1
  • TypeScript 4.1
  • tailwindcss 2.0.2
  • recoil 0.1.2

まずルートのコンポーネントDnDProvierでラップします。 今回は状態管理にrecoilを使用しているので、RecoilRootでもラップしています。 (状態管理はお好みのものを使ってください)

import { AppProps } from 'next/app';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { RecoilRoot } from 'recoil';
import 'tailwindcss/tailwind.css';

const App = ({ Component, pageProps }: AppProps) => {
  return (
    <RecoilRoot>
      <DndProvider backend={HTML5Backend}>
        <Component {...pageProps} />
      </DndProvider>
    </RecoilRoot>
  );
};

export default App;

次にitemtypeを列挙しておきます。今回は 1 種類だけTodoというtypeを定義します。

export const DnDItems = {
  Todo: 'Todo',
} as const;

export type DnDItems = typeof DnDItems[keyof typeof DnDItems];

ではドラッグするコンポーネントをみていきます。

import { VFC } from 'react';
import { useDrag } from 'react-dnd';
import { useRecoilState } from 'recoil';
import { droppedColumnState } from '../recoil/dropColumn';
import { DropResult } from './DropColumn';
import { DnDItems } from '../dnd/DnDItem';

type Props = {
  name: string;
};

export const DraggableItem: VFC<Props> = ({ name }) => {
  const [, setDroppedColumnNumber] = useRecoilState(droppedColumnState);

  const [collected, drag] = useDrag({
    // 必須: itemのtypeを指定
    item: {
      type: DnDItems.Todo,
    },
    // ドラッグが終わったとき(ドロップしたとき)にその結果を取得する
    // 結果はuseDropのdrop関数で返された値をmonitor.getDropResult()で取得できる。
    end: (_, monitor) => {
      const dropResult = monitor.getDropResult() as DropResult;
      if (dropResult) {
        // ドロップされたカラム番号をstateにセット
        setDroppedColumnNumber(dropResult.colNumber);
      }
    },
    // 状態を取得
    collect: (monitor) => {
      return { dragging: monitor.isDragging() };
    },
  });

  const { dragging } = collected;

  // ドラッグ中の場合はopacityを変えている
  const opacity = dragging ? 'opacity-50' : 'opacity-100';

  return (
    // refにdragを渡してドラッグ対象にする
    <div
      ref={drag}
      className={`flex justify-center items-center rounded-2xl h-28 w-40 bg-white ${opacity}`}
    >
      <div>{name}</div>
    </div>
  );
};

説明はコメントに書いてある通りです。 end 関数の callback でドロップ結果を受け取っているのがポイントですね。

では次にドロップ先のコンポーネントをみてみます。

import React, { VFC } from 'react';
import { useDrop } from 'react-dnd';
import { useRecoilValue } from 'recoil';
import { droppedColumnState } from '../recoil/dropColumn';
import { DraggableItem } from './DraggableItem';
import { DnDItems } from '../dnd/DnDItem';

export type DropResult = {
  colNumber: number;
};

type Props = {
  colNumber: number;
  backgroundColor: string;
};

export const Column: VFC<Props> = ({ colNumber, backgroundColor }) => {
  const [, drop] = useDrop({
    // 必須: ドラッグするコンポーネントと同じtypeを指定する
    accept: DnDItems.Todo,
    // ドロップされたときにオブジェクトを返す
    drop: () => ({ colNumber }),
  });

  // ドロップされたカラム番号(useRecoilValue(droppedColumnState))とpropsのcolNumberが一致している場合はドラッグされた
  const isDropped = useRecoilValue(droppedColumnState) === colNumber;

  return (
    <div
      // refにdropを渡してドロップ対象のコンポーネントにする。
      ref={drop}
      className={`flex justify-center items-center h-96 w-48 ${backgroundColor}`}
    >
      {/* ドロップされた場合はドラッグしたコンポーネントを表示 */}
      {isDropped && <DraggableItem name='Drag Item' />}
    </div>
  );
};

最後にこれらを使って todoリスト のページを作成してみます。

import { Column } from '../components/DropColumn';

const IndexPage = () => (
  <div className='grid grid-cols-3 justify-center items-center place-items-center'>
    <div>todo</div>
    <div>wip</div>
    <div>done</div>
    <div>
      <Column colNumber={1} backgroundColor='bg-yellow-300' />
    </div>
    <div>
      <Column colNumber={2} backgroundColor='bg-red-300' />
    </div>
    <div>
      <Column colNumber={3} backgroundColor='bg-blue-300' />
    </div>
  </div>
);

export default IndexPage;

完成しました。 react_dnd_sample

というわけで React DnD を使ってドラッグ&ドロップを実装できました。

実際に現場で使う際には公式の exampleも参考にしてみてください。

example も TypeScript で実装されているのが嬉しいですね。

最後に

hey社はソフトウェアエンジニアはもちろん、デザイナー、PM、CS、セールス、コーポレートなど全職種大募集中です!

hello.hey.jp