form-helpers
Rails Form Helpers Expert
Build correct, modern forms in Rails 8 using form_with and associated helpers.
When To Use This Skill
- Building any form in a Rails view (form_with, nested forms, selects, checkboxes)
- Adding nested attributes with fields_for and accepts_nested_attributes_for
- Choosing the right form helper (text_field, select, collection_select, etc.)
- Building custom form builders
- Integrating forms with Stimulus controllers
The One Rule
form_with is the ONLY form helper you use. Period.
- ❌
form_for— deprecated since Rails 5.1. Do not use. - ❌
form_tag— deprecated since Rails 5.1. Do not use. - ✅
form_with— the one true form helper.
If you see form_for or form_tag in existing code, migrate it to form_with when touching that file. If you're writing new code, there is zero reason to use anything else.
Two Modes of form_with
Model-backed forms (most common)
<%= form_with model: @article do |form| %>
<%= form.text_field :title %>
<%= form.submit %>
<% end %>
Rails infers everything: action URL, HTTP method (POST for new, PATCH for persisted), field name scoping (article[title]), submit button text ("Create Article" vs "Update Article").
URL-based forms (no model)
<%= form_with url: search_path, method: :get do |form| %>
<%= form.search_field :query %>
<%= form.submit "Search" %>
<% end %>
Use for search forms, external APIs, or any form not tied to a model.
Instructions
Step 1: Determine Form Type
| Scenario | Use |
|---|---|
| Creating/editing a model record | form_with model: @record |
| Search form | form_with url: path, method: :get |
| Form posting to external URL | form_with url: "https://...", authenticity_token: false |
| Namespaced resource (e.g. admin) | form_with model: [:admin, @article] |
| Nested resource | form_with model: [@parent, @child] |
Step 2: Check Existing Patterns
ALWAYS check the project's existing forms first:
# Find existing form patterns
rg "form_with" app/views/ --type erb
# Check for custom form builders
rg "FormBuilder" app/ --type ruby
# Check for any deprecated form helpers (migration candidates)
rg "form_for\|form_tag" app/views/ --type erb
Match existing project conventions. If the app uses a custom form builder, use it.
Step 3: Build the Form
Use the appropriate input helpers on the form builder object. Every helper takes the attribute name as its first argument.
Common input helpers:
<%= form_with model: @user do |form| %>
<%= form.text_field :name %>
<%= form.email_field :email %>
<%= form.password_field :password %>
<%= form.telephone_field :phone %>
<%= form.url_field :website %>
<%= form.textarea :bio, size: "70x5" %>
<%= form.number_field :age, in: 18..120 %>
<%= form.range_field :satisfaction, in: 1..10 %>
<%= form.date_field :birthday %>
<%= form.time_field :preferred_time %>
<%= form.datetime_local_field :available_at %>
<%= form.color_field :favorite_color %>
<%= form.hidden_field :referrer, value: "homepage" %>
<%= form.checkbox :terms_accepted %>
<%= form.submit %>
<% end %>
Step 4: Use Correct Select Helpers
Selects are where agents mess up most. Read the reference for full patterns.
Static options:
<%= form.select :status, ["Draft", "Published", "Archived"] %>
<%= form.select :status, [["Draft", "draft"], ["Published", "published"]] %>
From a collection (belongs_to):
<%= form.collection_select :city_id, City.order(:name), :id, :name %>
With option groups:
<%= form.grouped_collection_select :city_id, Country.order(:name), :cities, :name, :id, :name %>
With prompt/include_blank:
<%= form.select :category_id, categories, prompt: "Select a category" %>
<%= form.collection_select :author_id, Author.all, :id, :name, include_blank: "None" %>
Step 5: Handle Nested Attributes Correctly
This is the hardest part of Rails forms. Follow this pattern exactly.
1. Configure the model:
class Person < ApplicationRecord
has_many :addresses, inverse_of: :person, dependent: :destroy
accepts_nested_attributes_for :addresses, allow_destroy: true,
reject_if: :all_blank
end
2. Build empty children in controller:
def new
@person = Person.new
@person.addresses.build # at least one empty set of fields
end
def edit
@person = Person.find(params[:id])
@person.addresses.build if @person.addresses.empty?
end
3. Build the nested form:
<%= form_with model: @person do |form| %>
<%= form.text_field :name %>
<h3>Addresses</h3>
<%= form.fields_for :addresses do |address_form| %>
<div class="nested-fields">
<%= address_form.hidden_field :id %>
<%= address_form.text_field :street %>
<%= address_form.text_field :city %>
<%= address_form.label :_destroy, "Remove" %>
<%= address_form.checkbox :_destroy %>
</div>
<% end %>
<%= form.submit %>
<% end %>
4. Permit nested params:
def person_params
params.expect(person: [
:name,
addresses_attributes: [[:id, :street, :city, :_destroy]]
])
end
Critical notes on nested forms:
fields_forrenders NOTHING if the association is empty — you MUST build at least one childallow_destroy: truerequires the_destroyfield AND permitting:_destroyin strong paramsreject_if: :all_blankprevents saving empty nested records- Always include the hidden
:idfield for existing records (fields_for does this automatically) - The double array
[[:id, :street, ...]]inparams.expectis intentional — it means "array of hashes with these keys"
Step 6: Handle File Uploads
<%= form_with model: @user do |form| %>
<%= form.file_field :avatar %>
<%= form.submit %>
<% end %>
form_withautomatically setsenctype="multipart/form-data"when afile_fieldis present- In the controller,
params[:user][:avatar]is anActionDispatch::Http::UploadedFile - For production file handling, use Active Storage — don't roll your own
Multiple files:
<%= form.file_field :documents, multiple: true %>
Permit as array: params.expect(user: [documents: []])
Step 7: Strong Parameters for Complex Forms
Simple model:
params.expect(article: [:title, :body, :published])
With nested attributes:
params.expect(person: [
:name, :email,
addresses_attributes: [[:id, :street, :city, :state, :zip, :_destroy]]
])
With arrays (checkboxes, multi-select):
params.expect(article: [:title, tag_ids: []])
With rich text (Action Text):
params.expect(article: [:title, :body]) # :body is the rich text attribute name
Key Concepts
CSRF Protection
Every non-GET form automatically includes an authenticity_token hidden field. This is Rails' CSRF protection. Don't disable it unless posting to an external service:
<%# External API - disable token %>
<%= form_with url: "https://external.api/webhook", authenticity_token: false do |form| %>
HTTP Method Emulation
HTML forms only support GET and POST. Rails emulates PATCH, PUT, DELETE via a hidden _method field:
<%# This generates method="post" with a hidden _method="patch" %>
<%= form_with model: @article, method: :patch do |form| %>
For form_with model: @article on a persisted record, Rails sets PATCH automatically.
Record Identification
form_with model: @article figures out:
- New record? → POST to
/articles(create) - Persisted record? → PATCH to
/articles/:id(update) - Submit text → "Create Article" or "Update Article"
This requires resources :articles in your routes.
Namespaced & Nested Resources
# Admin namespace
form_with model: [:admin, @article]
# → POST /admin/articles or PATCH /admin/articles/:id
# Nested resource
form_with model: [@magazine, @article]
# → POST /magazines/:magazine_id/articles
# Deep nesting
form_with model: [:admin, @magazine, @article]
Custom Form Builders
When you repeat the same form patterns, create a custom builder:
# app/form_builders/application_form_builder.rb
class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
def text_field(attribute, options = {})
label(attribute) + super(attribute, options.merge(class: "form-input"))
end
end
Use it:
<%= form_with model: @user, builder: ApplicationFormBuilder do |form| %>
<%= form.text_field :name %> <%# Automatically includes label + CSS class %>
<% end %>
Or create a helper to use it by default:
# app/helpers/application_helper.rb
def app_form_with(**options, &block)
options[:builder] = ApplicationFormBuilder
form_with(**options, &block)
end
Rich Text (Action Text)
<%= form.rich_text_area :body %>
Requires Action Text to be installed (rails action_text:install). The attribute is declared on the model:
class Article < ApplicationRecord
has_rich_text :body
end
Radio Buttons and Checkboxes
Radio buttons — user picks one:
<%= form.radio_button :flavor, "vanilla" %>
<%= form.label :flavor_vanilla, "Vanilla" %>
<%= form.radio_button :flavor, "chocolate" %>
<%= form.label :flavor_chocolate, "Chocolate" %>
Collection radio buttons — from a collection:
<%= form.collection_radio_buttons :city_id, City.all, :id, :name %>
Checkboxes — single boolean:
<%= form.checkbox :terms_accepted %>
<%= form.label :terms_accepted, "I accept the terms" %>
Collection checkboxes — has_many or HABTM:
<%= form.collection_checkboxes :interest_ids, Interest.all, :id, :name %>
Note: checkbox (not check_box) generates a hidden field with value "0" so unchecked boxes still submit a value.
Date and Time Helpers
Prefer HTML5 native inputs (modern, mobile-friendly):
<%= form.date_field :born_on %>
<%= form.time_field :starts_at %>
<%= form.datetime_local_field :event_at %>
Select-based date/time (legacy, multi-select dropdowns):
<%= form.date_select :born_on %>
<%= form.time_select :starts_at %>
<%= form.datetime_select :event_at %>
Date selects produce multi-parameter attributes like born_on(1i), born_on(2i), born_on(3i). Active Record knows how to reassemble these.
Anti-Patterns
- Using
form_fororform_tag— alwaysform_with - Forgetting to build nested children —
fields_forrenders nothing for empty associations - Missing
_destroyin strong params — checkbox exists but destroy silently fails - Using
selectfor belongs_to without_idsuffix — the field name must be the foreign key - Hardcoding form action URLs — let
model:infer them, or use route helpers - Forgetting
allow_destroy: trueonaccepts_nested_attributes_for - Not adding
reject_if: :all_blank— empty nested forms create blank records - Manually setting
enctypefor file uploads —form_with+file_fieldhandles this - Using
_taghelpers inside a form builder block — use the form builder methods instead - Not permitting
idin nested attributes — needed to update (not duplicate) existing records
Dynamically Adding/Removing Nested Fields
For adding nested fields dynamically (without page reload), use Stimulus:
<%# Render a hidden template for new fields %>
<template data-nested-form-target="template">
<%= form.fields_for :addresses, Address.new, child_index: "NEW_RECORD" do |af| %>
<div class="nested-fields" data-new-record>
<%= af.text_field :street %>
<%= af.text_field :city %>
<%= af.hidden_field :_destroy, value: false, data: { nested_form_target: "destroy" } %>
<button type="button" data-action="nested-form#remove">Remove</button>
</div>
<% end %>
</template>
<div data-nested-form-target="container">
<%= form.fields_for :addresses do |af| %>
<div class="nested-fields">
<%= af.text_field :street %>
<%= af.text_field :city %>
<%= af.hidden_field :_destroy, value: false, data: { nested_form_target: "destroy" } %>
<button type="button" data-action="nested-form#remove">Remove</button>
</div>
<% end %>
</div>
<button type="button" data-action="nested-form#add">Add Address</button>
The Stimulus controller replaces NEW_RECORD in the template with a unique timestamp to ensure unique parameter names. See reference.md for the full Stimulus controller code.
Quick Reference: Input Helper → HTML Type
| Helper | HTML type |
|---|---|
text_field |
text |
email_field |
email |
password_field |
password |
telephone_field |
tel |
url_field |
url |
search_field |
search |
number_field |
number |
range_field |
range |
date_field |
date |
time_field |
time |
datetime_local_field |
datetime-local |
month_field |
month |
week_field |
week |
color_field |
color |
hidden_field |
hidden |
file_field |
file |
textarea |
<textarea> |
checkbox |
checkbox |
radio_button |
radio |
Debugging Forms
# Check what params your form submits
# In controller:
Rails.logger.debug params.inspect
# Check generated HTML
# In browser dev tools, inspect the <form> element for:
# - action (correct URL?)
# - method (post?)
# - hidden _method field (patch/delete?)
# - authenticity_token present?
# - field names correct (model[attribute]?)
# Common param issues with nested forms:
# - addresses_attributes vs addresses (must be _attributes)
# - Missing id field (creates duplicates instead of updating)
# - _destroy not permitted (silently ignored)
More from thinkoodle/rails-skills
testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4i18n
Expert guidance for Rails I18n (internationalization and localization). Use when working with translations, locale files, t() / l() helpers, lazy lookups, pluralization, interpolation, date/time/number formatting, model translations, error message translations, setting locale from URL/header/session, or organizing YAML translation files. Triggers on "i18n", "internationalization", "translation", "locale", "localize", "t()", "translate", "multilingual", "pluralization", "locale file", "YAML translation".
4action-cable
Expert guidance for implementing real-time features with Action Cable in Rails applications. Use when working with websockets, channels, subscriptions, broadcasting, live updates, push notifications, or Solid Cable. Covers connection authentication, channel creation, streaming, client-side JS, testing, and deployment. Clarifies when to use Action Cable directly vs Turbo Streams broadcasting.
4rails-components
Expert guidance for building reusable UI components in Rails using partials, CSS classes, and helpers. Use when creating components, partials, reusable UI patterns, empty states, modals, cards, buttons, badges, alerts, data tables, or any component pattern. Covers decision framework for CSS-only vs partial vs helper vs ViewComponent, proper use of local_assigns, block yielding, capture for slots, variant patterns, and component galleries.
4active-model
Build plain Ruby objects that integrate with Rails forms, validations, serialization, and Action Pack — without a database. Use when building form objects, search forms, API wrappers, configuration objects, virtual models, or any PORO that needs model-like behavior. Triggers on "active model", "form object", "plain Ruby object", "non-database model", "ActiveModel", "PORO", "service object validation", "virtual model", "tableless model", "search form", "ActiveModel::API", "ActiveModel::Model".
4active-storage
Expert guidance for Rails Active Storage — file uploads, attachments, image variants, direct uploads, and cloud service configuration. Use when working with file uploads, has_one_attached, has_many_attached, image processing, variants, S3/GCS/Azure storage, direct uploads, or file attachment patterns in Rails applications.
4