iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Forms

Rails forms are an end-to-end story: model validations, form_with helpers, server-rendered errors, Turbo for SPA-feel without JS, and Stimulus for the bits that need it. Mastering this combo replaces a JS framework on most server-rendered apps.

form_with + validations + Turbo + Stimulus

EXAMPLE
# 1) Model with validations
# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :customer
  has_many :line_items, dependent: :destroy
  accepts_nested_attributes_for :line_items, allow_destroy: true,
                                              reject_if: ->(a) { a[:sku].blank? }

  enum status: { new: 'new', paid: 'paid', shipped: 'shipped', cancelled: 'cancelled' }

  validates :customer, presence: true
  validates :total_cents, numericality: { greater_than_or_equal_to: 0 }
  validate  :at_least_one_line_item

  private

  def at_least_one_line_item
    errors.add(:base, 'add at least one item') if line_items.empty?
  end
end

# 2) Controller — uses strong params + Turbo response
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  before_action :set_order, only: %i[edit update destroy]

  def new;  @order = Order.new; @order.line_items.build end

  def create
    @order = Order.new(order_params)
    if @order.save
      respond_to do |fmt|
        fmt.html         { redirect_to @order, notice: 'Order placed' }
        fmt.turbo_stream { flash.now[:notice] = 'Order placed' }
      end
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @order.update(order_params)
      redirect_to @order, notice: 'Updated'
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def set_order; @order = Order.find(params[:id]) end

  def order_params
    params.require(:order).permit(:customer_id, :total_cents,
      line_items_attributes: %i[id sku qty price_cents _destroy])
  end
end

# 3) Form template — Rails 7 form_with defaults to Turbo
# app/views/orders/_form.html.erb
<%= form_with model: order do |f| %>
  <% if order.errors.any? %>
    <div role='alert' class='alert alert-danger'>
      <h2><%= pluralize(order.errors.count, 'error') %> stopped the save:</h2>
      <ul>
        <% order.errors.full_messages.each do |msg| %>
          <li><%= msg %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class='field'>
    <%= f.label :customer_id %>
    <%= f.collection_select :customer_id, Customer.all, :id, :name, include_blank: 'Select...' %>
  </div>

  <div class='field'>
    <%= f.label :total_cents %>
    <%= f.number_field :total_cents, min: 0 %>
  </div>

  <fieldset id='line-items' data-controller='nested-form'>
    <legend>Line items</legend>
    <%= f.fields_for :line_items do |item| %>
      <%= render 'line_item_fields', f: item %>
    <% end %>

    <template data-nested-form-target='template'>
      <%= f.fields_for :line_items, LineItem.new, child_index: 'NEW_ID' do |item| %>
        <%= render 'line_item_fields', f: item %>
      <% end %>
    </template>

    <button type='button' data-action='nested-form#add'>Add line item</button>
  </fieldset>

  <%= f.submit %>
<% end %>

# 4) Partial for ONE line item — shared between server-rendered and template
# app/views/orders/_line_item_fields.html.erb
<div class='line-item' data-new-record='<%= f.object.new_record? %>'>
  <%= f.hidden_field :_destroy %>
  <%= f.text_field   :sku, placeholder: 'SKU' %>
  <%= f.number_field :qty, value: 1, min: 1 %>
  <%= f.number_field :price_cents, placeholder: 'cents' %>
  <button type='button' data-action='nested-form#remove'>Remove</button>
</div>

# 5) Stimulus controller — Add / Remove line items without a page request
# app/javascript/controllers/nested_form_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
  static targets = ['template'];
  add() {
    const html = this.templateTarget.innerHTML.replaceAll('NEW_ID', Date.now());
    this.templateTarget.insertAdjacentHTML('beforebegin', html);
  }
  remove(event) {
    const row = event.target.closest('.line-item');
    if (row.dataset.newRecord === 'true') row.remove();
    else { row.querySelector('[name*=_destroy]').value = 1; row.style.display = 'none'; }
  }
}

# 6) Turbo Stream response for partial updates
# app/views/orders/create.turbo_stream.erb
<%= turbo_stream.prepend 'orders' do %>
  <%= render @order %>
<% end %>
<%= turbo_stream.update 'flash' do %>
  <%= render 'shared/flash' %>
<% end %>

# 7) Patterns to internalise
# - Strong params: always permit only the fields you accept
# - Server-rendered errors: do NOT re-implement client validation
# - Use Turbo Streams for inserts; redirects for full-page changes
# - Stimulus for tiny per-form interactivity; full React only when needed
# - status: :unprocessable_entity on render after failure (Turbo expects it)

# 8) Pitfalls
# - Forgetting accepts_nested_attributes_for -> nested params silently ignored
# - Permitting :id without _destroy -> users cannot delete nested records
# - Returning 200 OK on validation failure -> Turbo replaces the page badly
# - Heavy custom JS for what Stimulus + Turbo already cover
# - Forgetting CSRF token (form_with handles it; manual fetch does not)

Why it matters

Stimulus + Turbo cover ~80% of "we need React for this form" scenarios in Rails. Nested forms, validation errors, async submits, partial page updates — all handled by the framework with three or four short controllers. Reach for React when you actually need the component library; otherwise let Rails do the work.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
<%= form_with model: @post do |f| %>
    <%= f.label :title %>
    <%= f.text_field :title %>
    <%= f.submit 'Save' %>
<% end %>
Try it Yourself »

Discussion

Loading…