ruby erb javascript 77 lines · 4 tabs

Add/remove nested fields with Stimulus (no cocoon)

Shared by codesnips Jan 2026
4 tabs
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
4 files · ruby, erb, javascript Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Add/remove nested fields with Stimulus (no cocoon) — share card
Link copied