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…