skillZs
★ LIVE SKILL TAGS ★
>>> LIVE SKILLS INDEX <<<
* OPEN SOURCE *
NO LOGIN, NO TRACKING
※ REAL INSTALL DATA ※
← back to all skills
thinkoodle/rails-skills118 installs

form-helpers

Expert guidance for building forms in Rails 8 applications. Use when creating forms, form_with, form helpers, nested forms, select helpers, file uploads, form builders, accepts_nested_attributes_for, fields_for, collection_select, grouped_collection_select, date/time selects, checkboxes, radio buttons, rich text areas, or any form-related view code. Covers model-backed forms, URL-based forms, complex nested attributes with _destroy, custom form builders, CSRF tokens, strong parameters for nested forms, and Stimulus integration.

How do I install this agent skill?

npx skills add https://github.com/thinkoodle/rails-skills --skill form-helpers
view source ↗

Is this agent skill safe to install?

  • Gen Agent Trust Hubpass

    A professional developer skill for Rails 8 form construction. It provides patterns for form helpers, nested attributes, and file uploads. A minor security risk exists as the agent is instructed to match code patterns from the existing codebase, creating a surface for indirect prompt injection from potentially malicious comments in project files.

  • Socketpass

    No alerts

  • Snykpass

    Risk: LOW · No issues

What does this agent skill do?

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

ScenarioUse
Creating/editing a model recordform_with model: @record
Search formform_with url: path, method: :get
Form posting to external URLform_with url: "https://...", authenticity_token: false
Namespaced resource (e.g. admin)form_with model: [:admin, @article]
Nested resourceform_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_for renders NOTHING if the association is empty — you MUST build at least one child
  • allow_destroy: true requires the _destroy field AND permitting :_destroy in strong params
  • reject_if: :all_blank prevents saving empty nested records
  • Always include the hidden :id field for existing records (fields_for does this automatically)
  • The double array [[:id, :street, ...]] in params.expect is 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_with automatically sets enctype="multipart/form-data" when a file_field is present
  • In the controller, params[:user][:avatar] is an ActionDispatch::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

  1. Using form_for or form_tag — always form_with
  2. Forgetting to build nested children — fields_for renders nothing for empty associations
  3. Missing _destroy in strong params — checkbox exists but destroy silently fails
  4. Using select for belongs_to without _id suffix — the field name must be the foreign key
  5. Hardcoding form action URLs — let model: infer them, or use route helpers
  6. Forgetting allow_destroy: true on accepts_nested_attributes_for
  7. Not adding reject_if: :all_blank — empty nested forms create blank records
  8. Manually setting enctype for file uploads — form_with + file_field handles this
  9. Using _tag helpers inside a form builder block — use the form builder methods instead
  10. Not permitting id in 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

HelperHTML type
text_fieldtext
email_fieldemail
password_fieldpassword
telephone_fieldtel
url_fieldurl
search_fieldsearch
number_fieldnumber
range_fieldrange
date_fielddate
time_fieldtime
datetime_local_fielddatetime-local
month_fieldmonth
week_fieldweek
color_fieldcolor
hidden_fieldhidden
file_fieldfile
textarea<textarea>
checkboxcheckbox
radio_buttonradio

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)

Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.

<a href="https://skillzs.dev/skills/thinkoodle/rails-skills/form-helpers">View form-helpers on skillZs</a>