import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["wrapper", "template", "anchor"]
add(event) {
event.preventDefault()
const index = new Date().getTime()
const html = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, index)
this.anchorTarget.insertAdjacentHTML("beforebegin", html)
}
remove(event) {
event.preventDefault()
const row = event.target.closest("[data-nested-form-row]")
if (!row) return
const destroyInput = row.querySelector("input[name*='_destroy']")
if (destroyInput) {
destroyInput.value = "1"
row.style.display = "none"
} else {
row.remove()
}
}
}
<%= form_with model: @order do |form| %>
<div data-controller="nested-form">
<div data-nested-form-target="wrapper">
<%= form.fields_for :line_items do |item| %>
<%= render "line_item_fields", form: item %>
<% end %>
<div data-nested-form-target="anchor"></div>
</div>
<template data-nested-form-target="template">
<%= form.fields_for :line_items,
LineItem.new,
child_index: "NEW_RECORD" do |item| %>
<%= render "line_item_fields", form: item %>
<% end %>
</template>
<button type="button"
data-action="nested-form#add">
Add line item
</button>
<%= form.submit %>
</div>
<% end %>
<div data-nested-form-row>
<%= form.hidden_field :id %>
<%= form.hidden_field :_destroy %>
<%= form.label :description %>
<%= form.text_field :description %>
<%= form.label :quantity %>
<%= form.number_field :quantity %>
<button type="button"
data-action="nested-form#remove">
Remove
</button>
</div>
class OrdersController < ApplicationController
def update
@order = Order.find(params[:id])
if @order.update(order_params)
redirect_to @order, notice: "Order updated."
else
render :edit, status: :unprocessable_entity
end
end
private
def order_params
params.require(:order).permit(
:reference,
line_items_attributes: [:id, :description, :quantity, :_destroy]
)
end
end
class Order < ApplicationRecord
has_many :line_items, inverse_of: :order
accepts_nested_attributes_for :line_items,
allow_destroy: true,
reject_if: ->(attrs) { attrs[:description].blank? }
end
This snippet shows the classic Rails accepts_nested_attributes_for problem solved on the client with Stimulus, so adding and removing child rows never touches the server until submit. The core trick is a <template> tag holding one blank child form whose input names contain a placeholder index; on insert the placeholder is swapped for a unique number so Rails parses each row into a distinct nested-attributes hash.
The nested_form_controller.js tab defines the controller. It declares a wrapper target (the container that holds existing rows) and a template target (the hidden blueprint). The add action clones templateTarget.innerHTML, replaces every NEW_RECORD token with new Date().getTime() to guarantee a collision-free index, and appends it before an anchor node. Using a timestamp rather than a running counter avoids index reuse when rows are added, removed, and re-added within the same page, which would otherwise cause two rows to submit under the same key and silently overwrite each other.
Removal is handled differently for new versus persisted records. In remove, if the row has no database id it is simply detached from the DOM. If it is an existing record, the row is hidden and a hidden _destroy input is set to 1, which tells accepts_nested_attributes_for (with allow_destroy: true) to delete that association on save. This preserves the ability to undo-by-not-submitting and keeps unsaved edits intact.
The _form.html.erb tab wires the markup: data-controller, the data-nested-form-target attributes, and data-action click handlers. Rails' fields_for renders existing children, while the <template> renders a single set of fields built with child_index: 'NEW_RECORD' so the placeholder ends up in the input names.
The line_items_controller.rb tab shows the server side that makes this coherent: strong params permit the nested attributes array including :id and :_destroy, and the model enables allow_destroy with reject_if to drop blank rows. The trade-off is that all validation still happens on submit, so client-side add/remove is purely structural; complex conditional logic or server-computed defaults still require a request. For ordinary CRUD-style nested forms, though, this pattern is fast, dependency-light, and degrades gracefully.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.