📖

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の状態が不一致になり、リロードしたら順序が戻る…といった不自然な挙動になります。

そこで、「失敗時のロールバック」を組み合わせます。

具体的な手順は、以下のとおりです。

  1. 並び替え前の状態(兄弟ノード配列)をコピーして保存

  2. 楽観的にUI更新

  3. 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/foldersApi::FoldersController#index(一覧)
  • GET /api/folders/:id#show(詳細)
  • POST /api/folders#create(作成)
  • PATCH /api/folders/:id#update(更新)
  • DELETE /api/folders/:id#destroy(削除)

newedit(HTMLフォーム用)は API では不要なので含めない。


collection { patch :sort }

コレクション(集合)に対するカスタムアクションsortPATCHで追加。
memberではなくcollectionなので:idなし)

PATCH /api/folders/sortApi::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_idorder(配列)だけを受け取る宣言。

order: []は「配列として受け取りますよ」という合図。

Mass Assignment攻撃(マスアサインメント脆弱性)というハッキング手法を防いでおり、paramsに開発者が意図していないキー情報を仕込み、そのparamsを利用してデータベース上で不正を犯させない。


parent_id = permitted[:parent_id]

親フォルダのID。

root(最上位)を並び替えるときはnullが来ることがある。


order_ids = Array(permitted[:order]).map(&:to_i)

もしordernilでも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)

  • そのレコードのposition0..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_idnullになります。

Rails側ではwhere(parent_id: nil)IS NULLに変換されるため、この形で問題ありません。

・フロントは兄弟だけをorderに含める

並び替えは「同じ親フォルダ直下の兄弟」に限られます。

他の階層のIDが混ざると意図しない順序変更が発生するため、送信前に必ずフィルタリングしましょう。

・サーバー側は入力検証で常に守る

フロントが正しいデータを送ってくることを前提にせず、サーバー側で「親で絞る」「件数一致」「重複チェック」を行います。

これにより、バグや不正リクエストからDBを保護できます。

以上のような守りを固めておくことで、将来機能を拡張しても安心して運用できます。

まとめ

今回紹介したのは、

  • 楽観更新+失敗時ロールバックでUX改善

  • Rails側の検証でDB破壊を防ぐ

実装者としては、「見た目の心地よさ」と「データの安全性」の両立を意識することが重要です。

楽観更新はUIの快適さを飛躍的に向上させますが、それを安全に使うためにはサーバー側の堅牢なバリデーションが欠かせません。

この組み合わせを覚えておけば、並び替え機能だけでなく、あらゆる更新系UIの品質向上に活かせるはずです。

Discussion