はじめに
「ドラッグ&ドロップなんて簡単でしょ」
そう思っていた時期が私にもありました。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 フックを使い、リスト項目全体にドラッグリスナーを適用する形です。attributes と listeners をルート要素に展開するのが基本パターン。
動きました。リスト項目をドラッグすると、ちゃんと並び替えができる。
「簡単じゃん」
そう思った矢先、問題が発生しました。
問題発生:再生ボタンが押せない
音源リストには、各項目に以下の要素があります:
- 再生ボタン: タップで音源を再生
- タイトル編集欄: クリックで編集モードに
- 削除ボタン: タップで音源を削除
全体をドラッグ可能にした結果、再生ボタンをタップしようとすると...ドラッグが始まってしまう。
「あれ、再生できない」
何度タップしても、項目がドラッグされるだけ。再生ボタンが機能しない。
開発日記にはこう書きました:
音源はタイトル編集と再生ボタンがあるので、ドラッグハンドルを明示的に分ける必要があった
リストの各項目には複数のインタラクションがあるのに、全体をドラッグ対象にしてしまったのが問題でした。
解決策:ドラッグハンドルの分離
解決策は「ドラッグできる場所」を限定することでした。
attributes と listeners をルート要素ではなく、ハンドル部分だけに適用します。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回ロールバックを経験した
- 最初の実装で型エラーが発生
- Supabaseの型定義が手動更新だったため、
sound_filesカラムが認識されなかった- 安全な型処理版(オプショナルチェーン多用)で最終的に動作
この経緯を詳しく説明させてください。
1回目のロールバック:型エラー
Claude Code に音源リストを表示するコンポーネントの実装を依頼したところ、TypeScript の型エラーが発生しました。
「sound_files プロパティは存在しません」
おかしい。データベースには確かにカラムがある。Supabase のダッシュボードで確認済みだ。
「コードのタイポかな?」と思い、何度も見直しました。でも問題は見つからない。
2回目のロールバック:別のアプローチを試す
型エラーが解消しないので、別のアプローチを試しました。
as any でキャストしてみたり、オプショナルチェーンを多用してみたり。でも、どれも根本的な解決にはならない。
コードを書いては消し、書いては消し...を繰り返していました。
3回目で気づいた原因
3回目のロールバックの後、ふと思いました。
「もしかして、型定義が古いのでは?」
Supabase の型定義は、pnpm supabase gen types コマンドで生成します。データベースのスキーマが変わったら、このコマンドを再実行して型定義を更新する必要がある。
確認してみると...案の定、sound_files カラムを追加した後に型定義を更新していませんでした。
開発日記にはこう書きました:
型の手動更新の罠: Supabaseの型は
pnpm supabase gen typesで生成するが、新しいカラムを追加した後に忘れがち。型エラーが出たらまず型定義を確認する
型エラーが出たとき、私は「コードの問題」と決めつけていました。でも実際は「型定義が古い」という周辺の問題だった。
この経験から、型エラーに遭遇したときのチェックリストを作りました:
- タイポがないか
- インポートが正しいか
- 型定義が最新か(Supabase、GraphQL など)
3番目を最初に確認していれば、3回のロールバックは避けられたはずです。
コンポーネントの汎用化:ownerType パターン
ここまでで並び替え機能は動くようになりました。次の課題は「同じ機能を別の場所でも使いたい」という要望でした。
BandBridge では、以下の2箇所で音源アップロード UI が必要です:
- プロフィール: 個人の音源(最大3曲・20MB)
- バンド: バンドの音源(最大5曲・50MB)
最初は別々のコンポーネントを作ろうと思いました。ProfileSoundUpload と BandSoundUpload のように。
でも開発日記にはこう書きました:
SoundUploadの汎用化: 最初はプロフィール用とバンド用で別コンポーネントにしようと思ったが、90%以上のコードが同じだったので
ownerTypeprop で切り替える形に
実際にコードを見比べてみると、ほとんど同じでした。違うのは制限値だけ:
- プロフィール: 最大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(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。