Railsのform_forとform_withの違いを調べてみた

これは、「フィヨルドブートキャンプ Part 2 Advent Calendar 2023」の19日目の記事です。
昨日の記事は、machidaさんの「[文字起こし] TokyuRuby会議 14 で Tokyu.rb のロゴを作った話の LT をしました🎨」とpenoさんの「Ruby のメソッドのソースコード(C言語)を初めて読んだ」です。

今年のフィヨルドブートキャンプのアドベントカレンダーは、以下となります。
フィヨルドブートキャンプ Part 1 Advent Calendar 2023 - Adventar

フィヨルドブートキャンプ Part 2 Advent Calendar 2023 - Adventar

はじめに

Railsで5.1以降はform_forform_tagの代わりにform_withを使うことがRailsガイドに言及されています。 しかし、form_forform_withが似たメソッドで違いについてあまり理解できてないです。
類似記事は沢山ありますが、改めて自分で内部実装やドキュメント等を調べて違いを整理しました。

調査した環境のバージョンは以下となります。

form_withとform_forの違いまとめ

Railsガイドには、form_withの機能について以下のように記載されてあります。

Rails 5.1でform_withが導入されるまでは、form_withの機能はform_tagとform_forに分かれていました。

つまり、form_withは、汎用的にform_tagform_forの両機能を利用することが出来るということです。

form_withform_forの違いは結論から言うと、form_withには以下の2点の特徴があり、form_forは以下の2点とは逆の特徴を持ちます。

  • フォームタグにHTMLのclassidを付与しない
  • フィールドタグのmethodオプションにモデルの属性以外も指定可能

form_forはform_withを呼び出す

先程特徴の違いを2点書きましたが、form_forの実装内容を見るとform_withとの違いがより理解しやすいです。
というのも、form_forのコードを見ると分かりますが処理内容は以下のようになっています。(actionview7.0.7の場合)

  1. form_forのオプションをform_withのオプションに加工する
  2. form_withを呼び出す

そのためform_forform_withの違いとなる要素は、form_withで利用するオプションの違いを比較すれば良いわけです。

実際に例を見てオプションの差を確認します。Messageモデルを指定した場合にform_wiht内で利用されるオプションを比較してみます。 比較箇所はform_withで呼び出されるprivateメソッドのhtml_options_for_form_withで、optionshtml_optionsの値を見ます。

def html_options_for_form_with(url_for_options = nil, model = nil, html: {}, local: !form_with_generates_remote_forms,
  skip_enforcing_utf8: nil, **options)
  html_options = options.slice(:id, :class, :multipart, :method, :data, :authenticity_token).merge!(html)
  html_options[:remote] = html.delete(:remote) || !local
  html_options[:method] ||= :patch if model.respond_to?(:persisted?) && model.persisted?
  if skip_enforcing_utf8.nil?
    if options.key?(:enforce_utf8)
      html_options[:enforce_utf8] = options[:enforce_utf8]
    end
  else
    html_options[:enforce_utf8] = !skip_enforcing_utf8
  end
  # ここでの`options`と`html_options`を比較する
  html_options_for_form(url_for_options.nil? ? {} : url_for_options, html_options)
end

実行するform_withform_forのコードは下記になります。
比較のためにform_forform_withで指定するオプションは最低限です。

form_forのコード

form_for @message, url: messages_path do |f|
end

form_withのコード

form_with model: @message, url: messages_path do |f|
end

上記2つのコードを実行した場合に、form_with内で利用されるオプションの違いは以下となります。

options、html_optionsのキー form_forで呼び出す場合 form_withで呼び出す場合
options[:allow_method_names_outside_object] false true
options[:skip_default_ids] false false
options[:multipart] nil nil
html_options[:id] "edit_message_1" キーなし
html_options[:class] "edit_message" キーなし
html_options[remote] false false

違いとなるオプションは、:id:class:allow_method_names_outside_objectです。
:id:classはフォームタグにidを付与するものです。
:allow_method_names_outside_objectはRailsAPIDocsに記載がないですが、フィールドタグにモデル属性以外の許可を設定するものです。

form_forがモデルの属性以外を設定できない理由

form_forがフィールドタグにモデルの属性以外を指定できない理由を見てみます。
フィールドタグの制約に影響するデータはform_withオプションの:allow_method_names_outside_objectです。form_forを呼び出したときの:allow_method_names_outside_objectの値は、先程見たオプションの違いの表からfalseであることが分かりました。
これを利用するコードはフォームタグのヘルパーメソッドのActionView::Helpers::Tags::Base#valueになります。 フィールドタグのvalue属性を設定する値を取得するメソッドになります。

def value
  if @allow_method_names_outside_object
    object.public_send @method_name if object && object.respond_to?(@method_name)
  else
    object.public_send @method_name if object
  end
end

valueメソッドは、 :allow_method_names_outside_objectがtrueの場合は、public_sendを呼び出さないためnilを返します。

一方で:allow_method_names_outside_objectがfalseの場合は、public_sendを呼び出すためメソッドがない場合はエラーが発生します。
このような仕組みによってform_forがモデル属性以外を指定できないように制御しています。

最後に

今回はRails7の内部実装を見て違いについて理解を深めました。Rails6の場合だと実装内容が異なるので、今回は調査対象外にしました。Rails6からRails7で実装内容を変えた経緯を調べるのも面白そうです。