ライブラリを利用することなく短時間でシンプルなコードを利用してドラッグ&ドロップでテーブル行の並び替えをしたいと思ったことはないですか?本文書ではReact環境でドラッグ&ドロップを利用してテーブル行の並び替えを行う方法を説明しています。実装にはdragstart, dragend, dragover, dragenterイベントを利用しているのでReactでのdragイベントに馴染みがない人のdragイベントの学習にも利用することができます。

動作確認はmacOSを利用、Reactのバージョンは18.2です。

プロジェクトの作成

npx create-react-appコマンドを利用してReactのプロジェクトを作成します。任意の名前をつけてプロジェクトの作成を行ってください。


 % npx create-react-app react-table-row-reorder

プロジェクトの作成後は作成されたフォルダreact-table-row-reorderに移動します。


 % cd react-table-row-reorder

UserTable.jsファイルのの作成

srcフォルダにcomponentsフォルダを作成してその下にUserTable.jsファイルを作成します。作成後以下のコードを記述します。


const UserTable = () => {
  return <div style={{ margin: '2em' }}>UserTable</div>;
};

export default UserTable;

srcフォルダのApp.jsファイルを開いて作成したUserTableコンポーネントをimportします。


import './App.css';
import UserTable from './components/UserTable';

function App() {
  return (
    <div className="App">
      <UserTable />
    </div>
  );
}

export default App;

npm startコマンドで開発サーバを起動してUserTableコンポーネントの内容が表示されるか確認します。


 % npm start
UserTableコンポーネントの表示確認
UserTableコンポーネントの表示確認

データの取得

並び替えに利用するデータは無料で利用できるJSONPlaceHolderを利用します。https://jsonplaceholder.typicode.com/usersにアクセスすると10名分のユーザ情報を取得することができます。どのようなデータが取得できるかはブラウザからでも確認できます。

データを取得するためにuseEffect Hook, 取得したデータを保存するためにuseState Hookを利用します。useState Hookで定義したusersに保存したデータはmap関数を利用して展開します。map関数で設定しているindexは後ほど利用します。


import { useEffect, useState } from 'react';

const UserTable = () => {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    const getUsers = async () => {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/users'
      );
      const users = await response.json();
      setUsers(users);
    };
    getUsers();
  }, []);

  return (
    <div style={{ margin: '2em' }}>
      <table>
        <thead>
          <tr>
            <th>ID</th>
            <th>名前</th>
            <th>ユーザ名</th>
            <th>Email</th>
          </tr>
        </thead>
        <tbody>
          {users.map((user, index) => (
            <tr key={user.id}>
              <td>{user.id}</td>
              <td>{user.name}</td>
              <td>{user.username}</td>
              <td>{user.email}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

export default UserTable;

ブラウザで確認すると10名分のユーザ情報が一覧で表示されます。

ユーザ一覧の表示
ユーザ一覧の表示

テーブルには罫線が表示されていないのでスタイルの設定を行います。App.jsファイルではApp.cssファイルをimportしているのでスタイルの設定をApp.cssファイルで行います。

App.cssファイルに以下を追加します。


//略
table,
th,
td {
  border: 1px solid black;
  border-collapse: collapse;
  padding: 1em;
}

App.cssファイルに追加後再度ブラウザを確認するとテーブルには罫線がついて表示されます。

罫線付きのテーブルの表示
罫線付きのテーブルの表示

テーブル行の並び替えを実装するための準備は完了です。

並び替え処理

draggableの設定

現在の設定では行の要素をマウスで掴みドラッグを行うことはできません。要素をドラッグできるようにするためにはdraggable属性を設定する必要があります。tr要素にdraggable属性を設定します。


{users.map((user, index) => (
  <tr key={user.id} draggable={true}>
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
))}

設定後は移動した要素の上にマウスを移動して左ボタンで要素を掴むことができるようになります。ボタンを押している間要素をドラッグすることができます。下記の図はIDを10を持つtr要素をドラッグしています。

tr要素をドラッグ
tr要素をドラッグ

ドラッグした要素上で押していたボタンを外すとその場所に要素が追加できるように設定を行なっていきます。

dragstartイベントの設定

要素を掴んでドラッグを開始した直後に発火するdragstartイベントの設定を行います。dragstartイベントがドラッグ直後に発火するのかどうか確認を行います。dragstartイベントはonDragStartで取得することができイベントが発火したらdragStart関数を実行します。


{users.map((user, index) => (
  <tr key={user.id} draggable={true} onDragStart={() => dragStart()}>
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
))}

設定したdragStart関数を定義します。


const dragStart = () => {
  console.log('drag start');
};

要素をドラッグしてブラウザのデベロッパーツールのコンソールに”drag start”が表示されることを確認してください。

確認できたら次は、dragstartイベントでドラッグした要素のindex(配列の要素番号)を保存します。indexを保存するためにuseState HookでdragIndexを定義します。初期値をnullに設定しています。


const [users, setUsers] = useState([]);
const [dragIndex, setDragIndex] = useState(null);

dragstartイベントが発火した際にindexの情報を取得するためにdragStart関数の引数にindexを指定します。


{users.map((user, index) => (
  <tr key={user.id} draggable={true} onDragStart={() => dragStart(index)}>
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
))}

dragStart関数では引数で取得したindexを受け取ることができるので受け取ったindexをsetDragIndexの引数に設定してdragIndexに保存します。


const dragStart = (index) => {
  console.log('drag start', index);
  setDragIndex(index);
};

設定後、要素をドラッグすると”drag start”の文字列の横にドラッグした要素のindexが表示されることを確認してください。indexは配列の要素番号なのでドラッグした要素によって0から9の値が表示されます。

dragstartイベントの設定は完了したのでconsole.logの行は削除しておきます。

dragenterイベントの設定

ドラッグした要素が別の要素に入った時に発火するdragenterイベントをtr要素に設定します。dragenterイベントが発火されるとdragEnter関数が実行され引数にはindexを指定しています。indexにはドラッグした要素が入ってきた要素のindexが入ります。ドラッグしている要素のindexではありません。


{users.map((user, index) => (
  <tr
    key={user.id}
    draggable={true}
    onDragStart={() => dragStart(index)}
    onDragEnter={() => dragEnter(index)}
  >
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
))}

dragEnter関数でドラッグした要素のindexが保存されているdragIndexとドラッグした要素が入ってきた要素のindexを確認します。


const dragEnter = (index) => {
  console.log('index', index);
  console.log('dragIndex', dragIndex);
};

同じ要素をドラッグしている間はdragIndexは同じ値が表示され、tr要素を超えていく度に異なるindexがコンソールに表示されることを確認してください。

確認ができたら、indexとdragIndexの値が異なる場合のみ要素の入れ替えを行う処理を追加します。


const dragEnter = (index) => {
  if (index === dragIndex) return;
  setUsers((prevState) => {
    let newUsers = JSON.parse(JSON.stringify(prevState));
    const deleteElement = newUsers.splice(dragIndex, 1)[0];
    newUsers.splice(index, 0, deleteElement);
    return newUsers;
  });

  setDragIndex(index);
};

JSON.parse(JSON.stringify(presState))で配列のコピーを行なっています。コピーした配列newUsersでspliceメソッドとdragIndexを利用してドラッグした要素をnewUsersから削除しています。削除した要素(ドラッグした要素)がspliceメソッドの戻り値の配列番号の0に入っているのでdeleteElementに保存しています。再度spliceメソッドを実行して今度はindexの場所に削除した要素を追加しています。これで入れ替え処理を行うことができます。

spliceメソッドの処理の詳しい内容についてはMDNなどで確認してください。

setUsersで配列を入れ替えた後はsetDragIndexでdragIndexの値をドラッグした要素が入れ替わった後のindexを設定しています。

これで並び替えの処理は完了です。一番上のIDが1の要素をドラッグして一番下の要素に移動できるか確認してください。

一番上の要素を一番下に移動した場合
一番上の要素を一番下に移動した場合

dragoverイベント

OSやブラウザによってドラッグで要素を移動した後にドロップする(並び替えは完了)と半透明の要素が元の場所に戻るような動作をします。その動作を止めるためにdragoverイベントを設定します。dragoverイベントではデフォルトの処理を停止するためpreventDefaultを実行します。dragoverイベントでevent.preventDefaultを実行するとドラッグした要素をドロップしても半透明の要素が元の場所に戻るという動作がなくなります。


{users.map((user, index) => (
  <tr
    key={user.id}
    draggable={true}
    onDragStart={() => dragStart(index)}
    onDragEnter={() => dragEnter(index)}
    onDragOver={(event) => event.preventDefault()}
  >
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
))}

dragendイベント

ここではJSONPlaceHolderを利用しているのでデータの取得を行うことはできますがデータの保存を行うことができません。実際のアプリケーションでは並び替えを行なった後のデータを保存する処理が必要になります。データ保存の処理を行う場所としてdragendイベントを利用します。

dragendイベントはドロップした際に発火されます。


{users.map((user, index) => (
  <tr
    key={user.id}
    draggable={true}
    onDragStart={() => dragStart(index)}
    onDragEnter={() => dragEnter(index)}
    onDragOver={(event) => event.preventDefault()}
    onDragEnd={dragEnd}
  >
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
))}

dragEnd関数を追加し、ドラッグを止めた(ドロップ)時にイベントが発火するか確認します。


const dragEnd = () => {
  console.log('drop');
};

ドラッグのために押していたボタンを離した時にコンソールに”drop”が表示されることを確認してください。

並び替え後のデータをサーバに送信する等の処理をdragEnd関数の中に記述します。dragEndが実行される時は並び替えの処理が完了しているのでdragIndexの値を初期値のnullに戻しておきます。


const dragEnd = () => {
  // 並び替え後のusers配列をサーバに送信する処理を追加する
  setDragIndex(null)
};

ドラッグ要素を目立たせる

ドラッグした要素を目立たせるためにドラッグ要素にスタイルを設定します。

indexとdragIndexの値が等しい時にclassのdraggingを適用します。


{users.map((user, index) => (
  <tr
    key={user.id}
    draggable={true}
    onDragStart={() => dragStart(index)}
    onDragEnter={() => dragEnter(index)}
    onDragOver={(event) => event.preventDefault()}
    onDragEnd={dragEnd}
        className={index === dragIndex ? 'dragging' : ''}
  >
    <td>{user.id}</td>
    <td>{user.name}</td>
    <td>{user.username}</td>
    <td>{user.email}</td>
  </tr>
))}

draggingクラスはApp.cssに追加します。


//略
table,
th,
td {
  border: 1px solid black;
  border-collapse: collapse;
  padding: 1em;
}

.dragging {
  background-color: #eee;
}

ドラッグしている要素にスタイルを適用することでドラッグしている要素が先ほどよりもわかりやすくなりました。

ドラッグした要素にスタイルを適用
ドラッグした要素にスタイルを適用

作成したUserTable.jsファイルは下記の通りです。


import { useEffect, useState } from 'react';

const UserTable = () => {
  const [users, setUsers] = useState([]);
  const [dragIndex, setDragIndex] = useState(null);

  useEffect(() => {
    const getUsers = async () => {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/users'
      );
      const users = await response.json();
      setUsers(users);
    };
    getUsers();
  }, []);

  const dragStart = (index) => {
    setDragIndex(index);
  };

  const dragEnter = (index) => {
    if (index === dragIndex) return;
    setUsers((prevState) => {
      let newUsers = JSON.parse(JSON.stringify(prevState));
      const deleteElement = newUsers.splice(dragIndex, 1)[0];
      newUsers.splice(index, 0, deleteElement);
      return newUsers;
    });
    setDragIndex(index);
  };

  const dragEnd = () => {
    console.log('ここにサーバへの並び替え後のデータ送信処理を追加');
    setDragIndex(null);
  };

  return (
    <div style={{ margin: '2em' }}>
      <table>
        <thead>
          <tr>
            <th>ID</th>
            <th>名前</th>
            <th>ユーザ名</th>
            <th>Email</th>
          </tr>
        </thead>
        <tbody>
          {users.map((user, index) => (
            <tr
              key={user.id}
              draggable={true}
              onDragStart={() => dragStart(index)}
              onDragEnter={() => dragEnter(index)}
              onDragOver={(e) => e.preventDefault()}
              onDragEnd={dragEnd}
              className={index === dragIndex ? 'dragging' : ''}
            >
              <td>{user.id}</td>
              <td>{user.name}</td>
              <td>{user.username}</td>
              <td>{user.email}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

export default UserTable;