【2025年版】Railsのdestroyアクションにstatus: :see_otherを付けるべきか否か？

## TL;DR（長いので最初に結論）

- [この記事](https://qiita.com/jnchito/items/5c41a7031404c313da1f)が書かれた2021年当時は、destroyの`redirect_to`には`status: :see_other`を付けないと問題が起きる可能性があった
- turbo-rails 1.3.0がリリースされた2022年9月以降はかなりリスクが低くなった
- とはいえ、HTTPやブラウザの仕様を考えるとupdateやdestroyには常に`status: :see_other`を付けた方が安心（つまり、`status: :see_other`は付けるべき）
- Rails 7.1以降ならscaffoldで生成したコードに`status: :see_other`が付いている

## はじめに

以前、筆者はこんな記事を書きました。

https://qiita.com/jnchito/items/5c41a7031404c313da1f

この記事の中に「status: :see_other がないと非常に危険！！」という項目があります。この記事を書いた当時（2021年）はたしかに危険性が高かったのですが、現在ではそのリスクはかなり低くなっています。

しかし、なぜリスクが低くなったのかについては、様々な技術要素が絡み合っているため、ひとことで説明するのが非常に難しいです。そこでこの記事では「Railsのdestroyアクションに`status: :see_other`を付けるべきか否か？」を判断するために必要な各技術要素について順に説明し、最後になぜ現在ではリスクが低くなったのかを説明します。

結論に至るまでちょっと難しい話題が続きますが、Railsアプリケーション開発者はしっかり理解しておきたい内容なので、じっくり読んで理解を深めていってください。

## HTTPのリダイレクトの仕様を理解する

### 302だけではないリダイレクト用のステータスコード

「リダイレクト用のステータスコードって302でしょ？」と思いこんでいる方は要注意。実は302以外にも様々なステータスコードが存在します（下記MDNを参照）。

https://developer.mozilla.org/ja/docs/Web/HTTP/Redirections

この中でも今回の話の中心になるのが、以下の2種類です。

- 302 Found
- 303 See Other

### 302 Found

302 Foundはリダイレクト時のメソッドの扱いに要注意です。

GETでリクエストが送られてきた場合はGETでリダイレクトします。しかし、それ以外のメソッド（POST、PUT、PATCH、DELETE）でリクエストされた場合は、リダイレクト時に使われるメソッドの仕様があいまいです。

> メソッドの扱い
> 仕様書ではメソッドの変更を意図していませんが、実際はメソッドを変更するユーザーエージェントが存在します。
>
> [HTTP のリダイレクト \- HTTP \| MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Redirections)

慣習的にPOSTでリクエストが送信された場合のみ、GETでリダイレクトするユーザーエージェント（ブラウザ）が多いようです。

これは言い換えると、PUT/PATCH/DELETEでリクエストが送られてきた場合は、GETではなく、リクエスト時に使われたPUT/PATCH/DELETEでリダイレクトする可能性が高いことを意味します。

### 303 See Other

一方、303 See Otherはどのメソッドでリクエストが送られてきても必ずGETでリダイレクトします。

> メソッドの扱い	
> GET メソッドは変更しません。 他のメソッドは GET に変更します
>
> 主な使用例
> ページの再読み込みによって操作が再度実施されることを防ぐために、PUT や POST の後のリダイレクトで使用します。
> 
> [HTTP のリダイレクト \- HTTP \| MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Redirections)

主な仕様例で書かれているように、Railsアプリケーションなどでデータを更新し、それから他の画面へリダイレクトする場合は、まさにこの303 See Otherが最適です。

## HTMLフォームの仕様を理解する

ブラウザでHTMLフォーム（つまりformタグ）を使う場合、method属性に指定できるのはGETかPOSTだけです。

https://developer.mozilla.org/ja/docs/Web/HTML/Element/form

### RailsがPUT/PATCH/DELETEでリクエストする場合の仕組み

そのため、PUT/PATCH/DELETEのリクエストを送信したい場合、Railsでは隠し項目として`_method`を用意し、ここに指定したメソッドをフォームのメソッドと見なすようになっています。

```html 
<!-- ブラウザからはPOSTとしてフォームを送信する -->
<form action="/blogs/123" method="post">
  <!-- Railsは_methodで指定されているPATCHをこのフォームのメソッドと見なす -->
  <input type="hidden" name="_method" value="patch">
```

## Railsのbutton_toとlink_toの違いを理解する

### button_toはformタグを出力する
`button_to` を使うとHTMLにはformタグが出力されます。

```erb 
<%= button_to 'Delete', [task.project, task], method: :delete %>
```

↓ 出力結果 (formタグを出力)

```html 
<form action="/projects/3/tasks/6" method="post">
  <input type="hidden" name="_method" value="delete">
  <button type="submit">Delete</button>
</form>
``` 

erbのコード例では `method: :delete` を指定していますが、HTML上ではformタグのmethodはPOSTが、隠し項目の`_method`にはDELETEが指定されている点に注目してください。

### link_toはaタグを出力し、Turboがリクエストを送信する
一方、`link_to`を使った場合はaタグが出力されます。

```erb 
<%= link_to 'Delete', [task.project, task], data: { turbo_method: :delete } %>
```

↓ 出力結果 ( aタグを出力)

```html 
<a data-turbo-method="delete" href="/projects/6/tasks/12">Delete</a>
```

`link_to`はaタグなので、通常であればGETでリクエストを送信しますが、`data: { turbo_method: :delete }`のようなオプションを付けることでPOST/PUT/PATCH/DELETEのリクエストが送信できます。

なぜaタグなのにGET以外のリクエストが送信できるのかというと、TurboがJavaScriptの力でaタグの送信リクエストをPOST/PUT/PATCH/DELETEのリクエストに改変しているからです。

## Turboで実装されている data-turbo-method の挙動を理解する

Turboは`data-turbo-method="delete"`のような属性が付いていると、リンククリック時に動的に生成したformオブジェクトのmethod属性に、`data-turbo-method`で指定されたメソッド名をそのままセットします。

```javascript 
// https://github.com/hotwired/turbo/blob/v8.0.11/src/observers/form_link_click_observer.js#L50-L51
const method = link.getAttribute("data-turbo-method")
if (method) form.setAttribute("method", method)
```

このため、通常のHTMLフォームでは指定できないPUT/PATCH/DELETEがmethod属性に指定されることになります。

さらに、Turboはそのformオブジェクトをそのままsubmitするのではなく、fetch APIを使ってリクエストする処理に変換しています。

```javascript
// https://github.com/hotwired/turbo/blob/v8.0.11/src/core/drive/form_submission.js#L30-L41
export class FormSubmission {
  // ...

  constructor(delegate, formElement, submitter, mustRedirect = false) {
    const method = getMethod(formElement, submitter)
    const action = getAction(getFormAction(formElement, submitter), method)
    const body = buildFormData(formElement, submitter)
    const enctype = getEnctype(formElement, submitter)

    this.delegate = delegate
    this.formElement = formElement
    this.submitter = submitter
    this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype)
    this.mustRedirect = mustRedirect
  }
  
  // ...
```

```javascript 
// https://github.com/hotwired/turbo/blob/v8.0.11/src/http/fetch_request.js#L131
  async perform() {
    // ...
    try {
      // ...
      } else {
        this.response = fetch(this.url.href, fetchOptions)
      }
```

ゆえに、Turboでは本来ブラウザからは送信できないはずのPUT/PATCH/DELETEを、（`fetch`リクエストとして）送信できるのです。

## turbo-rails gemのバージョンによる挙動の違いを理解する

RailsでTurboを使う場合、直接Turboを使うのではなく、turbo-rails gemをインストールして使うケースが多いと思います。turbo-rails gemはバージョンによって、`data-turbo-method`が指定された場合のリクエスト送信の挙動が異なります。

### バージョン1.1.1まで

Turboのデフォルトの実装をそのまま利用します。つまり、

```html 
<a data-turbo-method="delete" href="/projects/6/tasks/12">Delete</a>
```

というようなリンクがあれば、`/projects/6/tasks/12`に対してDELETEでリクエストが送信されます。

### バージョン1.3.0以降

バージョン1.3.0以降（バージョン1.2はなぜか存在しません）ではTurboのフォーム送信イベントをフックして、Railsでよく利用される「formのmethodはPOST + `_method`でPUT/PATCH/DELETEを指定」という送信形式に変換します。

https://github.com/hotwired/turbo-rails/pull/370

つまり、

```html 
<a data-turbo-method="delete" href="/projects/6/tasks/12">Delete</a>
```

というようなリンクがあれば、`/projects/6/tasks/12`に対してPOSTでリクエストが送信されます（DELETEメソッドであることは`_method`で指定する）。

## 本題：status: :see_otherを付けなければいけないのはどんなとき？

さて、ここまでの前提条件が揃ってようやく本記事の本題に入れます。

以前書いた[こちらの記事](https://qiita.com/jnchito/items/5c41a7031404c313da1f)では「`status: :see_other` がないと非常に危険！！」と書きましたが、これは以下の条件を満たしていたためです。

- `link_to`メソッドで`data: { turbo_method: :delete }`を指定している → TurboがDELETEでリクエストを送信する
- turbo-railsのバージョンが1.1.1以下 → Turboの実装がそのまま使われる
- controllerで`redirect_to`に`status: :see_other`を指定していない → ステータスコードとして302が使われる。302だとブラウザはGETではなくDELETEでリダイレクト先にリクエストを送信する

このような挙動になるため、リダイレクト先のURL（たとえば `/projects/3`）がDELETEリクエストを受け付ける場合、意図せずid=3のProjectが削除される危険性がありました。

よって、この問題を回避するために`redirect_to`に`status: :see_other`を付ける必要があったわけです。

```ruby 
redirect_to @project, status: :see_other
```

`status: :see_other`を付ければステータスコードは303になります。303であれば必ずGETでリダイレクトするため、本来の意図通りid=3のProjectページを表示してくれます。

## こういうケースでは status: :see_other を付けなくても大丈夫

以下のいずれかの条件に合致すれば`redirect_to`に`status: :see_other`を付けなくても問題ありません。

- `button_to`メソッドを使っている → PUT/PATCH/DELETEであってもブラウザはPOSTでフォームを送信する。POSTであれば、ステータスコードが302であってもGETでリダイレクトされる
- turbo-railsのバージョンが1.3.0以上 → `data-turbo-method="delete"`が指定されていても、内部的にPOSTでフォームを送信するように切り替えてくれる

特に、turbo-railsのバージョンが1.3.0以上だった場合の恩恵は非常に大きいですね。[turbo-rails 1.3.0のリリースは2022年9月](https://github.com/hotwired/turbo-rails/releases/tag/v1.3.0)なので、これ以降にrails newした場合は`status: :see_other`を付けなくても大丈夫な可能性が高くなります。

## 【重要】でも、本当に万全を期すなら……

ただし、`redirect_to`に`status: :see_other`を付けない、というのは言い換えると、「リクエストが必ずPOSTで送られてくる前提でアプリケーションが作られている」ということです。

Webアプリケーションはその仕組み上、誰でもどこからでも自由にリクエストを送信できます。その気になればDELETEでリクエストを送ることも可能です（例: あえてturbo-railsを使わず、素のTurboがリクエストを送信する場合など）。`status: :see_other`がない状態で、もし予期せずDELETEでリクエストが送られてきた場合は302、つまりDELETEでリダイレクトすることになり、大事なリソースが削除されてしまうリスクは残ったままになります。

というわけで、万全を期すならdestroyアクションの`redirect_to`は必ず`status: :see_other`を付ける、とした方が安全です。

```ruby 
def destroy
  @task.destroy
  # 万が一DELETEでリクエストされた場合に備えてstatus: :see_otherを付けておく
  redirect_to @project, status: :see_other
end
```

また、同じことはupdateアクションにも言えます。なぜならPUT/PATCHでリクエストが送られてきた場合、302でリダイレクトするとPUT/PATCHでリダイレクト先にアクセスしてしまうためです。よって、updateアクションにも`status: :see_other`を付けるべきです。

```ruby
def update
  if @task.update(task_params)
    # 万が一PUT/PATCHでリクエストされた場合に備えてstatus: :see_otherを付けておく
    redirect_to @task, status: :see_other
  else
    render :edit, status: :unprocessable_entity
  end
end
```

## Rails 7.1以降ならもう大丈夫？

実はバージョン7.1以降のRailsではscaffoldコマンド（または`rails g controller`コマンド）を実行すると最初からupdateアクションやdestroyアクションに`status: :see_other`が付くようになっています。

https://github.com/rails/rails/pull/45383

https://github.com/rails/rails/commit/5ed37b35d666b833aeccb14a4cacd2926251232d

よって、Rails 7.1以降を使っている場合は心配無用です！・・・と言いたいところですが、注意点が2つあります。

### 注意点1. :see_otherが付くのはコードを自動生成したときだけ

`status: :see_other`が付くのはscaffoldコマンドや`rails g controller`コマンドでコントローラのコードを生成したときだけです。

Rails 7.0以前に書いた既存のコードや、どこかからコピペしてきたcontrollerのコードについては、自分で`status: :see_other`を付ける必要があります。

### 注意点2. jbuilderを使っているとdestroyにしか:see_otherが付かない

scaffoldコマンドや`rails g controller`コマンドを使ったコードの自動生成は、jbuilder gemをインストールしている（Gemfile.lockにjbuilderが含まれている）とRails本体ではなく、jbuilder側の自動生成コードが使われます。

実は本記事の執筆時点（2025年2月）の最新バージョンであるjbuilder 2.13.0では、destroyアクションにしか`status: :see_other`が付きません。そのため、updateアクションについては自分で`status: :see_other`を付ける必要があります。

この問題についてはすでにupdateアクションにも`status: :see_other`を付けるプルリクエストが作成されているため、そのうち解決されるかもしれません。

https://github.com/rails/jbuilder/pull/583

## まとめ

というわけで本記事では「Railsのdestroyアクションにstatus: :see_otherを付けるべきか否か？」と題して、技術的な内容を深掘りしてみました。

長々と書いてきましたが、簡単に（雑に？）まとめると、

- [この記事](https://qiita.com/jnchito/items/5c41a7031404c313da1f)が書かれた2021年当時は、destroyの`redirect_to`には`status: :see_other`を付けないと問題が起きる可能性があった
- turbo-rails 1.3.0がリリースされた2022年9月以降はかなりリスクが低くなった
- とはいえ、HTTPやブラウザの仕様を考えるとupdateやdestroyには常に`status: :see_other`を付けた方が安心（つまり、`status: :see_other`は付けるべき）
- Rails 7.1以降ならscaffoldで生成したコードに`status: :see_other`が付いている

ということになります。

### Special thanks to: Hotwire.loveのみなさん
このトピックは先日開催されたHotwire.love Meetup vol.41で挙がった話題です。

この日は参加者全員でワイワイガヤガヤとコードやissueを眺めながら「`status: :see_other`とは何なのか」を楽しく深掘りしました。参加者のみなさん、どうもありがとうございました！

https://hotwire-love.connpass.com/event/345054/

Hotwire.love はHotwire関連の話題を楽しくおしゃべりするオンラインコミュニティです。初心者経験者を問わず、どなたでもお気軽にご参加ください！😄

https://hotwire-love.connpass.com/