class Recipe < ApplicationRecord
has_many :ingredients, inverse_of: :recipe, dependent: :destroy
accepts_nested_attributes_for :ingredients,
allow_destroy: true,
reject_if: :all_blank
validates :title, presence: true
def ingredients_with_placeholder
ingredients.any? ? ingredients : ingredients.build
end
end
<%= form_with model: @recipe, data: { controller: "nested-form" } do |form| %>
<div class="field">
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div data-nested-form-target="container">
<%= form.fields_for :ingredients do |ingredient| %>
<%= render "ingredient_fields", form: ingredient %>
<% end %>
</div>
<template data-nested-form-target="template">
<%= form.fields_for :ingredients,
@recipe.ingredients.build,
child_index: "NEW_RECORD" do |ingredient| %>
<%= render "ingredient_fields", form: ingredient %>
<% end %>
</template>
<button type="button" data-action="nested-form#add">Add ingredient</button>
<div class="actions"><%= form.submit %></div>
<% end %>
<div class="nested-fields" data-nested-form-target="row">
<%= form.hidden_field :_destroy %>
<div class="field">
<%= form.label :name %>
<%= form.text_field :name %>
</div>
<div class="field">
<%= form.label :quantity %>
<%= form.text_field :quantity %>
</div>
<button type="button" data-action="nested-form#remove">Remove</button>
</div>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "template"]
add(event) {
event.preventDefault()
const index = new Date().getTime()
const html = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, index)
this.containerTarget.insertAdjacentHTML("beforeend", html)
}
remove(event) {
event.preventDefault()
const row = event.target.closest("[data-nested-form-target='row']")
const destroyField = row.querySelector("input[name*='_destroy']")
if (destroyField && destroyField.value !== "") {
destroyField.value = "1"
row.style.display = "none"
} else {
row.remove()
}
}
}
This snippet builds add/remove nested fields for a Rails form without the cocoon gem, using fields_for, accepts_nested_attributes_for, and a small Stimulus controller. The pattern solves the classic problem of letting a user attach an arbitrary number of child records (here, line items on a recipe) in a single form submission, while keeping the client-side logic tiny and framework-idiomatic.
The Recipe model declares accepts_nested_attributes_for :ingredients with allow_destroy: true and reject_if. allow_destroy is what makes removal work on the server: when a nested hash carries _destroy set to a truthy value, Rails deletes that associated record on save. reject_if guards against blank rows the user added but never filled in, so empty template rows don't create junk records.
The _form.html.erb view renders existing ingredients with fields_for, then defines a hidden <template> holding one blank field group. The key trick is child_index: "NEW_RECORD", which produces field names like recipe[ingredients_attributes][NEW_RECORD][name]. That placeholder is swapped for a unique index at insert time so each added row posts as a distinct nested record rather than overwriting one another. The data-nested-form-target and data-action attributes wire the markup to the Stimulus controller, and the template's HTML is escaped into a data attribute so the browser doesn't try to submit it.
In nested_form_controller.js, add reads the template target, replaces every NEW_RECORD with new Date().getTime() for a collision-free index, and inserts the fragment before the add button. remove handles two cases: brand-new rows (no persisted id) are simply removed from the DOM, while existing rows are hidden and their _destroy input flipped to 1 so the server destroys them on submit. Removing a persisted row from the DOM entirely would silently leave it in the database, which is the subtle pitfall this branch avoids.
This approach trades a dependency for a few dozen lines of code that are easy to read, debug, and adapt. It degrades reasonably and stays close to Rails conventions, making it a good default for most nested-form needs.
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.