React + Railsで安全なドラッグ&ドロップ並び替え機能:楽観更新とRails側バリデーション実装例
はじめに
React(TypeScript)+ Ruby on Rails(APIモード)で、ドラッグ&ドロップによる並び替え機能を実装するとき、以下のような課題に直面します。
-
ネットワーク遅延でUI更新がもたつく
-
APIエラー時に順序が壊れる
-
不正なリクエストでDBの順序が破壊される
本記事では、
フロント側は「楽観更新+失敗時ロールバック」、
サーバー側は「親で絞る+件数検証」
という2つの仕組みを組み合わせた、安全で快適な並び替え機能の実装例を紹介します。
並び替え機能における課題
並び替え機能を実装する際、以下の2点が課題になります。
1. UIのもたつき
ドラッグ完了後、サーバー応答を待ってからUIを更新すると、数百ms〜数秒のラグが発生し、操作感が悪くなります。
2. DB順序の破壊リスク
サーバー側で入力検証をしていないと、別親のIDや不足・重複したID配列を送られた際に、関係ないデータのpositionまで上書きしてしまいます。
フロントエンド実装(React)
使用ライブラリ
-
dnd-kit
軽量なドラッグ&ドロップライブラリ。 -
React Hooks(useState)
並び替え対象の抽出
まず、並び替え対象は現在表示している親フォルダの直下(兄弟ノード)だけに絞ります。
const parentId = null; // root階層の場合
const siblings = folders.filter((folder) => folder.parent_id === parentId);
const orderedSiblings = siblings.slice().sort((a, b) => a.position - b.position);
onDragEndの楽観更新+失敗時ロールバック
楽観更新とは?
「楽観更新(Optimistic UI / Optimistic Update)」とは、サーバーからの応答を待たずに、先にUIを更新してしまう手法です。
通常、要素の並び替えは以下のような手順を踏みます。
-
ユーザーが並び替え操作
-
APIにリクエスト送信
-
サーバーの処理完了を待つ
-
返ってきた結果でUI更新
しかし、ネットワークが遅い場合、操作後の画面反映がワンテンポ遅れてしまいます。
一方、楽観更新では順序が逆です。
-
ユーザーが並び替え操作
-
即座にUIを更新(サーバーの処理成功を「楽観」して先に見せる)
-
APIにリクエスト送信
-
成功ならそのまま、失敗なら元に戻す(ロールバック)
以上のフローにより、並び替えがUI上に即反映されるため、ネットワーク遅延やサーバー処理時間の影響をユーザー側にほぼ感じさせません。
実装上の工夫
楽観更新を使用する際、サーバーが失敗したときのケアが必要です。
これを怠ると、UIとDBの状態が不一致になり、リロードしたら順序が戻る…といった不自然な挙動になります。
そこで、「失敗時のロールバック」を組み合わせます。
具体的な手順は、以下のとおりです。
-
並び替え前の状態(兄弟ノード配列)をコピーして保存
-
楽観的にUI更新
-
API失敗時に、保存しておいた配列でUIを元に戻す
実際のコード例
const onDragEnd = async (ev: DragEndEvent) => {
const { active, over } = ev;
if (!over || active.id === over.id) return;
const oldIndex = orderedSiblings.findIndex((f) => f.id === active.id);
const newIndex = orderedSiblings.findIndex((f) => f.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
// ロールバック用に現在の並びをコピー
const prevSiblings = orderedSiblings.slice();
// 並び替え後の配列を作成(0..nにposition振り直し)
const reordered = arrayMove(orderedSiblings, oldIndex, newIndex);
const updated = reordered.map((folder, index) => ({
...folder,
position: index,
}));
// 楽観的にUI更新
setFolders((prev) =>
prev.map((folder) =>
folder.parent_id === parentId
? (updated.find((f) => f.id === folder.id) ?? folder)
: folder
)
);
try {
const res = await fetch(`${API_BASE}/folders/sort`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parent_id: parentId,
order: updated.map((f) => f.id),
}),
});
if (!res.ok) throw new Error(`sort failed: ${res.status}`);
} catch (err) {
console.error('並び替えの保存に失敗しました:', err);
// ロールバック
setFolders((prev) =>
prev.map((folder) =>
folder.parent_id === parentId
? (prevSiblings.find((f) => f.id === folder.id) ?? folder)
: folder
)
);
alert('並び替えの保存に失敗しました。');
}
};
サーバー側実装(Rails)
ルーティングとコントローラーのsortアクションを実装していきます。
↓参考
parent_id = 親フォルダのid
position = 同じ親を持つ子フォルダ自身の立ち位置。“左からの順番(0,1,2…)”というイメージ。
ルーティングのコード概観
namespace :api, defaults: { format: :json } do
resources :folders, only: %i[index show create update destroy] do
collection { patch :sort }
end
end
ルーティングのコード解読
namespace :api
URLが /api/...になる。
API専用のエリアを作ってるイメージ。
コントローラのクラス名は Api:: 名前空間になる(例:Api::FoldersController)。
defaults: { format: :json }
レスポンスは基本JSONを想定(HTMLじゃなくデータを返す API 用)。
resources :folders, only: %i[index show create update destroy] do
folders資源に対して、RESTのうち以下の5つのアクションだけを作成。
(index, show, create, update, destroy)
つまり、次のURLが自動で生えてくる(全部 /api 付き)
-
GET /api/folders→Api::FoldersController#index(一覧) -
GET /api/folders/:id→#show(詳細) -
POST /api/folders→#create(作成) -
PATCH /api/folders/:id→#update(更新) -
DELETE /api/folders/:id→#destroy(削除)
※ newとedit(HTMLフォーム用)は API では不要なので含めない。
collection { patch :sort }
コレクション(集合)に対するカスタムアクションsortをPATCHで追加。
(memberではなくcollectionなので:idなし)
PATCH /api/folders/sort → Api::FoldersController#sort
兄弟フォルダの並び替えなど、複数レコードを一度に更新する処理に向いている。
sortアクションのコード概観
def sort
permitted = params.permit(:parent_id, order: [])
parent_id = permitted[:parent_id]
order_ids = Array(permitted[:order]).map(&:to_i)
# 検証
return render json: { error: 'order must not be empty' }, status: :unprocessable_entity if order_ids.empty?
return render json: { error: 'duplicates not allowed' }, status: :unprocessable_entity if order_ids.uniq.size != order_ids.size
children_ids = Folder.where(parent_id: parent_id).pluck(:id)
unless order_ids.sort == children_ids.sort
return render json: { error: 'order must include all child ids' }, status: :unprocessable_entity
end
# 更新
Folder.transaction do
order_ids.each_with_index do |id, idx|
Folder.where(id: id, parent_id: parent_id)
.update_all(position: idx, updated_at: Time.current)
end
end
head :no_content
end
sortアクションのコード解読
① パラメータの受け取りと整形
permitted = params.permit(:parent_id, order: [])
Strong Parameters(危険なキーを弾く仕組み)で、parent_idとorder(配列)だけを受け取る宣言。
order: []は「配列として受け取りますよ」という合図。
Mass Assignment攻撃(マスアサインメント脆弱性)というハッキング手法を防いでおり、paramsに開発者が意図していないキー情報を仕込み、そのparamsを利用してデータベース上で不正を犯させない。
parent_id = permitted[:parent_id]
親フォルダのID。
root(最上位)を並び替えるときはnullが来ることがある。
order_ids = Array(permitted[:order]).map(&:to_i)
もしorderがnilでもArray(nil) => []にして落ちないようにする保険。
.map(&:to_i)は各要素を整数化("3" → 3)。
&:to_iはブロックの省略記法({ |x| x.to_i }と同じ意味)。
② 入力検証(防御的プログラミング)
return render json: { error: 'order must not be empty' }, status: :unprocessable_entity if order_ids.empty?
orderが空配列はダメ。「並べ替えする気あるの?」という未入力エラー。
422 Unprocessable Entity を返す(リクエストの形は正しいけど、中身が扱えない)。
return render json: { error: 'duplicates not allowed' }, status: :unprocessable_entity if order_ids.uniq.size != order_ids.size
order_ids の中に同じ数字が2回以上あれば、処理を中断して 422 エラーを返すという意味。
Rubyの.uniqは配列の重複要素を削除するメソッド。
[3, 1, 2, 1, 3].uniq
# => [3, 1, 2]
sizeで何をしているかというと
order_ids.size → 元の配列の要素数
order_ids.uniq.size → 重複を消した配列の要素数
これを比較して、
同じなら → 重複なし
違ったら → 重複あり(削除で短くなった)
order_ids = [3, 1, 2]
order_ids.size # => 3
order_ids.uniq.size # => 3
違わない → エラーにならない
order_ids = [3, 1, 2, 1]
order_ids.size # => 4
order_ids.uniq.size # => 3
違う → エラーを返す
children_ids = Folder.where(parent_id: parent_id).pluck(:id)
unless order_ids.sort == children_ids.sort
return render json: { error: 'order must include all child ids' }, status: :unprocessable_entity
end
その親の直下にいる子のID全てをDBから取得(pluck(:id)はIDカラムの値を配列で取得する)。
order_ids.sort == children_ids.sort
順番は問わないで、同じ要素集合かをチェック(過不足がないか)。
例:子が[3,5,8]のとき
orderが[5,3,8] → OK(並べ替えの順序は自由)
orderが[5,3] → NG(8が抜けてる)
orderが[5,3,9] → NG(知らない 9 が混ざってる)
ここまでが**「壊されないためのガード」。
フロントのバグや改造で「別親のID」「足りないID」「ダブり」などが紛れ込んでも、DBが破壊されない。
③ 更新(トランザクションで一括・オールオアナッシング)
Folder.transaction do
order_ids.each_with_index do |id, idx|
Folder.where(id: id, parent_id: parent_id)
.update_all(position: idx, updated_at: Time.current)
end
end
Folder.transaction
- この中のDB操作は全部成功したら確定、途中で失敗したら全部なかったことにする(ロールバック)。
- 例えば、3件目でエラーが出たら、1件目や2件目の更新も元に戻る=中途半端な状態を防ぐ。
each_with_index
-
order_idsを先頭から見て、0,1,2… と “並べ替え後の新しい順番” をidxに入れてくれる。
where(id: id, parent_id: parent_id)
- 親で絞る。万が一
orderに別親のIDが混ざってても、ここで更新されないようにダブルガード。
update_all(position: idx, updated_at: Time.current)
-
そのレコードの
positionを0..nに振り直す。 -
updated_atもついでに更新しておくと、あとで「いつ並び替えたか」が分かる。 -
update_allはバリデーション/コールバックを通さない高速更新。今回は並び順だけ触るのでOK。(重たい副作用が必要ならupdateを使う)
補足:高速化したい場合
DBが大きくなったらCASE WHENで一括更新もあり(やや上級)。まずは読みやすい今の書き方で十分。
④ レスポンス
head :no_content
-
204 No Content を返す。「処理は成功、返すボディはないよ」の合図。
-
フロントは成功・失敗だけ見ればよい(UIは楽観更新で最新になっているため)。
動作確認
今回の並び替え機能は、次のような流れで動きます。
1. フロントでドラッグ操作 → UI即時更新
ユーザーが移動させたい要素をドラッグ&ドロップすると、サーバーへの通信結果を待たずに即座にUIが並び替わります。
これが「楽観更新」の効果です。
→ 操作した瞬間に結果が見えるため、UX(操作感)は非常に軽快です。
2. RailsにPATCH /api/folders/sortが送信される
楽観更新後、裏側では並び替えたIDの配列と親IDがRails APIに送信されます。
このとき送られるorderには兄弟フォルダの全IDが入っていることが前提です。
3. 成功時 → 何もせず終了
APIが200番台(今回は204)を返せば、フロント側はすでにUIを更新済みなので何も追加処理は必要ありません。
ユーザーから見れば「ただスムーズに並び替えが終わった」ように見えます。
4. 失敗時 → ロールバックで元の順序に戻す
ネットワークエラーやサーバー側バリデーションの失敗時は、事前に保存しておいた並び替え前の状態を使ってUIを元に戻します。
これにより、DBとUIの不整合を防ぎ、ユーザーに「保存できなかった」ことを明確に伝えられます。
以上の一連の流れにより、操作の快適さとデータの安全性の両立が可能になります。
実装時の注意点
実際の運用や拡張を見据えると、以下のポイントを守ることが重要です。
・ルート階層はparent_id: nullで扱う
最上位階層の並び替えではparent_idがnullになります。
Rails側ではwhere(parent_id: nil)がIS NULLに変換されるため、この形で問題ありません。
・フロントは兄弟だけをorderに含める
並び替えは「同じ親フォルダ直下の兄弟」に限られます。
他の階層のIDが混ざると意図しない順序変更が発生するため、送信前に必ずフィルタリングしましょう。
・サーバー側は入力検証で常に守る
フロントが正しいデータを送ってくることを前提にせず、サーバー側で「親で絞る」「件数一致」「重複チェック」を行います。
これにより、バグや不正リクエストからDBを保護できます。
以上のような守りを固めておくことで、将来機能を拡張しても安心して運用できます。
まとめ
今回紹介したのは、
-
楽観更新+失敗時ロールバックでUX改善
-
Rails側の検証でDB破壊を防ぐ
実装者としては、「見た目の心地よさ」と「データの安全性」の両立を意識することが重要です。
楽観更新はUIの快適さを飛躍的に向上させますが、それを安全に使うためにはサーバー側の堅牢なバリデーションが欠かせません。
この組み合わせを覚えておけば、並び替え機能だけでなく、あらゆる更新系UIの品質向上に活かせるはずです。
Discussion