@dnd-kit/sortable でドラッグ&ドロップ並び替えを実装する

2026.02.02
Share:

はじめに

「ドラッグ&ドロップなんて簡単でしょ」

そう思っていた時期が私にもありました。React でリストの並び替え機能を実装するだけ。ライブラリを入れて、サンプルコードをコピーすれば動くはず。

ところが実際にやってみると、3回のロールバックを経験することになりました。

この記事では、BandBridge で「音源の並び替え」機能を実装する中で学んだことを共有します。同じ落とし穴にハマる人が減れば幸いです。

BandBridge とは

まず背景を説明させてください。

BandBridge は、ミュージシャン同士のマッチングサービスです。バンドを組みたい人が、メンバーを探したり、スカウトを受けたりできる。

このサービスでは、ユーザーが自分の音源(デモ音源や過去の演奏)をアップロードできます。アップロードした音源は一覧で表示され、検索結果のプロフィールカードにも代表曲として表示されます。

ここで問題が発生しました。

なぜ並び替えが必要だったのか

2026年1月17日の開発日記にはこう書きました:

音源並び替え: ミュージシャンにとって「代表曲」を最初に表示したい需要がある。並び替え機能があれば、自分のベストな曲を一番目立つ位置に配置できる

BandBridge の検索結果カードには、最初の音源のみが表示されます。配列の先頭にある曲が「顔」になるわけです。

でも、アップロードした順番が必ずしもベストとは限りません。後からアップロードした曲の方がクオリティが高いこともある。ユーザーが自分で代表曲を選べないのは、明らかに不便でした。

「並び替え機能を付けよう」

そう決めたのは良かったのですが、ここからが想定外の連続でした。

ライブラリ選定:なぜ @dnd-kit なのか

React のドラッグ&ドロップライブラリは複数あります。有名どころだと:

  • react-beautiful-dnd: Atlassian 製。Trello ライクな UI に強い
  • react-dnd: 低レベル API。自由度は高いが学習コストも高い
  • @dnd-kit: モダンで軽量。アクセシビリティ対応が充実

どれを選ぶか迷うところですが、私の場合は迷いませんでした。

開発日記にも書いた通り:

@dnd-kit/sortable の導入: ギャラリー画像の並び替えで既に使っていたので、同じパターンを踏襲

BandBridge では、プロフィールのギャラリー画像の並び替えに既に @dnd-kit を使っていました。新しいライブラリを学ぶより、既存の知見を活かす方が早い。

個人開発では「学習コスト」も重要な判断材料です。新しい技術を試したい気持ちはありますが、限られた時間の中では「既に知っているもの」を使う方が賢明なことも多いです。

最初の実装:公式ドキュメント通りにやってみた

@dnd-kit の公式ドキュメントを見ながら、Claude Code で基本的な実装を行いました。

useSortable フックを使い、リスト項目全体にドラッグリスナーを適用する形です。attributeslisteners をルート要素に展開するのが基本パターン。

動きました。リスト項目をドラッグすると、ちゃんと並び替えができる。

「簡単じゃん」

そう思った矢先、問題が発生しました。

問題発生:再生ボタンが押せない

音源リストには、各項目に以下の要素があります:

  1. 再生ボタン: タップで音源を再生
  2. タイトル編集欄: クリックで編集モードに
  3. 削除ボタン: タップで音源を削除

全体をドラッグ可能にした結果、再生ボタンをタップしようとすると...ドラッグが始まってしまう。

「あれ、再生できない」

何度タップしても、項目がドラッグされるだけ。再生ボタンが機能しない。

開発日記にはこう書きました:

音源はタイトル編集と再生ボタンがあるので、ドラッグハンドルを明示的に分ける必要があった

リストの各項目には複数のインタラクションがあるのに、全体をドラッグ対象にしてしまったのが問題でした。

解決策:ドラッグハンドルの分離

解決策は「ドラッグできる場所」を限定することでした。

attributeslisteners をルート要素ではなく、ハンドル部分だけに適用します。Lucide の GripVertical アイコンを使い、視覚的にも「ここを掴んで動かせる」ことを示しました。

<div ref={setNodeRef} style={style}>
  {/* ハンドル部分のみにドラッグリスナーを適用 */}
  <button {...attributes} {...listeners}>
    <GripVertical className="h-4 w-4" />
  </button>

  {/* 他のインタラクションはハンドル外 */}
  <button onClick={() => playSound(item.id)}>再生</button>
  <input value={item.name} onChange={...} />
</div>

これで、再生ボタンやタイトル編集は正常に機能するようになりました。ドラッグはハンドル(縦線のアイコン)を掴んだときだけ発動します。

開発日記の学びにも書きました:

ドラッグ&ドロップのUX: ハンドル(つまみ部分)を明示することで、他のインタラクション(再生ボタン、編集など)との競合を防げる

一見当たり前のことですが、公式ドキュメントの例をそのままコピーすると、この問題に気づきにくいです。

もう一つの問題:3回のロールバック

ドラッグハンドルの問題を解決して「これで完成」と思った矢先、また別の問題に直面しました。

開発日記から引用します:

バンドカード音源プレビューの実装: 3回ロールバックを経験した

  1. 最初の実装で型エラーが発生
  2. Supabaseの型定義が手動更新だったため、sound_files カラムが認識されなかった
  3. 安全な型処理版(オプショナルチェーン多用)で最終的に動作

この経緯を詳しく説明させてください。

1回目のロールバック:型エラー

Claude Code に音源リストを表示するコンポーネントの実装を依頼したところ、TypeScript の型エラーが発生しました。

sound_files プロパティは存在しません」

おかしい。データベースには確かにカラムがある。Supabase のダッシュボードで確認済みだ。

「コードのタイポかな?」と思い、何度も見直しました。でも問題は見つからない。

2回目のロールバック:別のアプローチを試す

型エラーが解消しないので、別のアプローチを試しました。

as any でキャストしてみたり、オプショナルチェーンを多用してみたり。でも、どれも根本的な解決にはならない。

コードを書いては消し、書いては消し...を繰り返していました。

3回目で気づいた原因

3回目のロールバックの後、ふと思いました。

「もしかして、型定義が古いのでは?」

Supabase の型定義は、pnpm supabase gen types コマンドで生成します。データベースのスキーマが変わったら、このコマンドを再実行して型定義を更新する必要がある。

確認してみると...案の定、sound_files カラムを追加した後に型定義を更新していませんでした。

開発日記にはこう書きました:

型の手動更新の罠: Supabaseの型は pnpm supabase gen types で生成するが、新しいカラムを追加した後に忘れがち。型エラーが出たらまず型定義を確認する

型エラーが出たとき、私は「コードの問題」と決めつけていました。でも実際は「型定義が古い」という周辺の問題だった。

この経験から、型エラーに遭遇したときのチェックリストを作りました:

  1. タイポがないか
  2. インポートが正しいか
  3. 型定義が最新か(Supabase、GraphQL など)

3番目を最初に確認していれば、3回のロールバックは避けられたはずです。

コンポーネントの汎用化:ownerType パターン

ここまでで並び替え機能は動くようになりました。次の課題は「同じ機能を別の場所でも使いたい」という要望でした。

BandBridge では、以下の2箇所で音源アップロード UI が必要です:

  1. プロフィール: 個人の音源(最大3曲・20MB)
  2. バンド: バンドの音源(最大5曲・50MB)

最初は別々のコンポーネントを作ろうと思いました。ProfileSoundUploadBandSoundUpload のように。

でも開発日記にはこう書きました:

SoundUploadの汎用化: 最初はプロフィール用とバンド用で別コンポーネントにしようと思ったが、90%以上のコードが同じだったので ownerType prop で切り替える形に

実際にコードを見比べてみると、ほとんど同じでした。違うのは制限値だけ:

  • プロフィール: 最大3曲・20MB
  • バンド: 最大5曲・50MB

この程度の差分のために、コンポーネントを2つ作るのは過剰です。ownerType という prop を追加し、この値で制限を切り替えることにしました。

type OwnerType = "profile" | "band";

const LIMITS = {
  profile: { maxFiles: 3, maxSize: 20 * 1024 * 1024 },
  band: { maxFiles: 5, maxSize: 50 * 1024 * 1024 },
};

function SoundUpload({ ownerType }: { ownerType: OwnerType }) {
  const limits = LIMITS[ownerType];
  // ...
}

これで90%のコード重複を避けられました。

開発日記の学びにも書きました:

コンポーネントの汎用化: 似たようなコンポーネントは早めに統合すべき。後から統合するより、最初から汎用的に作る方が楽

「似たようなコンポーネントを作りそうだな」と思ったら、最初から汎用化を意識した方が良いです。後から統合しようとすると、微妙な差分を吸収するのが面倒になります。

この経験から学んだこと

ドラッグ&ドロップの実装を通じて、いくつかの教訓を得ました。

1. 公式ドキュメントの例は「最小構成」

公式ドキュメントのサンプルコードは、動作確認のための最小構成です。実際のアプリでは、他のインタラクション(ボタン、入力欄など)との共存を考える必要があります。

サンプルをそのままコピーするのではなく、「自分のユースケースでは何が必要か」を考えることが大切です。

2. 型エラー=コードの問題とは限らない

型エラーが出たとき、私はコードを疑い続けました。でも実際の原因は「型定義が古い」という周辺の問題でした。

特に Supabase や GraphQL など、外部スキーマから型を生成している場合、型定義の更新忘れは頻繁に起こります。

3. 早めの汎用化が吉

「似たようなコンポーネントを作りそうだな」と思ったら、最初から汎用化を意識しましょう。後から統合するより、最初から ownerType のような切り替え機構を入れておく方が楽です。

まとめ

@dnd-kit/sortable でドラッグ&ドロップを実装して学んだこと:

問題解決策学び
他のボタンと競合するハンドル部分にのみリスナーを適用公式例は最小構成。実際のUIに合わせる
型エラーが解消しない型定義の更新を確認コード以外の原因も疑う
似たコンポーネントが増えるownerType パターンで汎用化早めに統合する方が楽

ドラッグ&ドロップは UX を向上させる機能ですが、他のインタラクションとの共存を意識した設計が重要です。

そして、問題が起きたときに「コードの問題」と決めつけず、型定義やスキーマなど周辺の問題も疑う習慣を持つことが大切だと学びました。

「ドラッグ&ドロップなんて簡単でしょ」

今ならこう答えます。

「基本は簡単。でも、実際のアプリに組み込むときは、いくつかの落とし穴がある」

この記事が、同じ落とし穴を避ける助けになれば幸いです。


関連記事

Zeronova avatar

Zeronovaゼロノバ

Product Manager / AI-Native Builder

Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。

関連プロダクト

BandBridge(バンドブリッジ)

ミュージシャンとバンドをつなぐ