投稿日:2026/8/8
更新日:2026/8/9

ドラッグ&ドロップをライブラリなしで実装するときの、ブラウザ標準 API の整理。React ではなく素の HTML / JavaScript としてどう動くかを先に押さえる。カンバン(カードを Todo / Doing / Done の列に移す)を例にする。
dragstart → dragover → drop。dragstart で dataTransfer.setData()、drop で getData()。 これがドラッグ中のデータの受け渡し口。dragover で preventDefault() を呼ばないと drop が発火しない。 ここが一番のハマりどころ。ref / state でも動く。それでも setData() を使うのは、ブラウザや OS の境界を越えられるのが dataTransfer だけだから。| カンバンの例 | |
|---|---|
| ドラッグされる要素 | カード |
| 運ばれるデータ | card.id |
| ドロップ先 | Todo / Doing / Done の列 |
「何を」「どのデータを持って」「どこに」落とすか、を分けて考えると迷いにくい。
sequenceDiagram
participant U as ユーザー
participant C as カード(ドラッグ元)
participant DT as dataTransfer
participant Z as 列(ドロップ先)
U->>C: つかむ
activate C
C->>DT: dragstart で setData し card.id を入れる
U->>Z: 列の上へ動かす
Z->>Z: dragover で preventDefault し落とせる場所にする
U->>Z: 離す
Z->>DT: drop で getData する
DT-->>Z: card.id
Z->>Z: アプリ側の状態を更新する
C->>C: dragend で一時状態を片付ける
deactivate C
任意の要素をドラッグ可能にするには draggable="true" を付ける。
<div id="card-1" draggable="true">カード1</div>
そのうえで dragstart を拾う。
const card = document.getElementById("card-1");
card.addEventListener("dragstart", (event) => {
event.dataTransfer.setData("text/plain", "card-1");
});
dragstart はつかんだ瞬間に発火する。つまりこのタイミングで「何を運ぶのか」を決める。
テキスト選択・画像・リンクは既定でドラッグ可能なので
draggableは不要。それ以外のdivなどは明示的に付ける必要がある。
dataTransfer は何者かドラッグ中だけ存在する、一時的なデータの入れ物。
flowchart LR
A["dragstart"] -->|"setData で入れる"| B["dataTransfer"]
B -->|"getData で読む"| C["drop"]
カード移動なら、カードオブジェクト全部ではなくまず ID だけ入れるのが分かりやすい。
// ドラッグ元
event.dataTransfer.setData("text/plain", "card-1");
// ドロップ先
const cardId = event.dataTransfer.getData("text/plain");
drop イベントは「どこに落とされたか」は分かる。しかしそれだけでは「何を持っていたか」が分からない。
なので dragstart の時点で card.id を入れておき、drop で取り出す。これが HTML Drag and Drop API のお作法。
setData() なのか(React なら ref でよくない?)自アプリの中だけで完結する操作なら、ref や state にドラッグ中のアイテムを持たせる設計でも動く。それでも setData() を使う理由は、ブラウザや OS の境界を越えてデータを渡せるのが dataTransfer だけだから。
ref / state は当然ながら自分の JavaScript 環境の中でしか有効ではない。setData() でブラウザ標準のドラッグ&ドロップに載せると、アプリの外へ出せる。
flowchart LR
subgraph APP["自分の React アプリ"]
R["ref / state"]
C["カード"]
end
subgraph OS["ブラウザ / OS"]
E["テキストエディタ"]
T["別タブの他サイト"]
end
C -->|"setData"| DT["dataTransfer"]
R -.->|"届かない"| OS
DT --> E
DT --> T
1回のドラッグ操作に対して、複数の形式でデータを載せられる。
// 同じ要素に対して異なる形式のデータを仕込んでおく
event.dataTransfer.setData("text/plain", "https://example.com");
event.dataTransfer.setData("text/html", '<a href="https://example.com">Example</a>');
こうしておくと、ドロップ先の対応環境に応じた形式をブラウザが自動で選んで渡す。
| ドロップ先 | 渡る形式 |
|---|---|
| プレーンテキストのみ対応(テキストエディタなど) | text/plain の https://example.com |
| リッチテキスト対応(ワープロ・チャットツールなど) | text/html のリンク付きテキスト |
ref に1つの値を持たせるだけでは、この出し分けはできない。
URL を運ぶなら
text/uri-listが標準の型。text/plainと併記しておくと落とせる先が広がる。
ref / state 共有だと、ドラッグ元とドロップ先が Context などで繋がっている必要があり、コンポーネントが離れているほど共有の設定が煩雑になる。
一方 drop はブラウザのネイティブイベントなので、その場で getData() すれば依存関係を持たずにデータを引ける。
| 状況 | 選択 |
|---|---|
| 自アプリの画面内で完結する(カンバンの並び替えなど) | ref / state でも動く |
| 外部アプリや別タブにドロップさせたい | setData() が必要 |
| リッチテキストとプレーンテキストの両方を持たせたい | setData() が必要 |
| OS 標準のドラッグ&ドロップ挙動と連携したい | setData() が必要 |
内部完結でも setData() に寄せておくと、後から外部連携が必要になったときに書き換えが小さく済む。
ここが一番つまずく。HTML 要素は既定ではドロップ先として扱われない。
ドロップ先にしたい要素で dragover を拾って preventDefault() する。
<div id="drop-zone">ここに落とす</div>
const dropZone = document.getElementById("drop-zone");
dropZone.addEventListener("dragover", (event) => {
event.preventDefault(); // ← これで「dropしてよい場所」になる
});
dropZone.addEventListener("drop", (event) => {
event.preventDefault();
const cardId = event.dataTransfer.getData("text/plain");
console.log("drop:", cardId);
});
preventDefault() を呼ぶ2か所は役割が違う| 呼ぶ場所 | 目的 |
|---|---|
dragover |
drop 先として成立させるため。これが無いと drop が発火しない |
drop |
ブラウザの既定処理を止めて、アプリ側で処理するため |
特に dragover 側が重要。
React なしだとこれだけで動く。
<div id="card" draggable="true">カード</div>
<div id="drop-zone">ここに落とす</div>
const card = document.getElementById("card");
const dropZone = document.getElementById("drop-zone");
card.addEventListener("dragstart", (event) => {
event.dataTransfer.setData("text/plain", "card");
});
dropZone.addEventListener("dragover", (event) => {
event.preventDefault();
});
dropZone.addEventListener("drop", (event) => {
event.preventDefault();
const id = event.dataTransfer.getData("text/plain");
dropZone.append(document.getElementById(id));
});
流れはこうなっている。
card をつかむ → dragstart → dataTransfer に "card" を入れるdrop-zone の上に移動 → dragover → preventDefault()drop-zone の上で離す → drop → dataTransfer から "card" を読む → DOM を移動する| イベント | 発火タイミング | 主な役割 |
|---|---|---|
dragstart |
つかんだ時 | dataTransfer に運ぶデータを入れる |
drag |
ドラッグ中(連続) | 必要なら移動中の処理をする |
dragenter |
ドロップ先候補に入った時 | ハイライト開始など |
dragover |
ドロップ先候補の上にいる間(連続) | preventDefault() して drop 可能にする |
dragleave |
ドロップ先候補から出た時 | ハイライト解除など |
drop |
離した時 | dataTransfer からデータを読んで反映する |
dragend |
ドラッグ操作が終わった時 | 一時状態を片付ける |
まずはこの形で覚える。
// dragstart: setData する
// dragover: preventDefault する
// drop: preventDefault → getData → 状態を更新する
// dragend: 一時状態を片付ける
React では DOM を直接動かすより、state を更新して再描画するのが自然。
// 素の DOM: DOM を直接動かす
dropZone.append(document.getElementById(id));
// React: state を変えて再描画する
setCards((current) =>
current.map((card) =>
card.id === cardId ? { ...card, columnId: nextColumnId } : card,
),
);
考え方は同じ(drop された → 何が落ちたか知る → 落ちた場所に反映する)。違うのは反映方法だけ。
| 素の DOM | React | |
|---|---|---|
| 反映方法 | DOM を直接動かす | state を変えて再描画する |
| 属性の書き方 | draggable="true" |
draggable(JSX の真偽値) |
| イベント登録 | addEventListener("dragstart", ...) |
onDragStart / onDragOver / onDrop |
drop だけ書けばよくない?足りない。ドロップ先として成立させるには dragover の preventDefault() が必要。「drop ハンドラを書いたのに一度も呼ばれない」の原因はほぼこれ。
dataTransfer じゃなくて state だけでもよくない?React アプリ内で完結するなら state だけでも実装できる。ただし Web 標準としてはドラッグ中のデータ受け渡し口は dataTransfer であり、ファイルドロップ・URL ドロップ・外部アプリからのテキストドロップも同じ仕組みに乗る。詳しくは「なぜ setData() なのか」の節を参照。
dragover で getData() しても空文字が返るdragover の時点ではデータは保護モードにあり、中身を読めない。読めるのは drop の中だけ。ドラッグ中に種類で分岐したいなら dataTransfer.types を見る。
dragleave でハイライトが点滅するdragenter / dragleave は子要素をまたぐたびに発火する。ドロップ先の内側に子要素があると、そこに入った瞬間に親の dragleave が飛んでハイライトが消える。カウンタで入退場を数えるか、drop と dragend で必ず解除する形にする。
HTML Drag and Drop API はタッチイベントに対応していない。モバイル対応が要るならポインタイベントで自前実装するか、対応ライブラリを使う。
この記事の例は順番の入れ替えではなく「所属列の変更」(card.columnId を "todo" から "doing" に変える)。同じ列内の上下並び替えまでやるなら、order を持たせるか配列そのものの順序を入れ替える必要がある。
dragstart で setData、drop で getDatadragover の preventDefault() が無いと drop は来ないdrop の preventDefault() はブラウザ既定処理の抑止ref / state で代用できるのは自アプリ内で完結する場合だけ。外部アプリ・別タブ・複数 MIME タイプが絡むなら setData() 一択