Back in 2020, I wrote about using the Cocoon gem with Webpacker in Rails 6. Cocoon stopped getting updates years ago, and Webpacker was retired from Rails in version 7. jQuery is gone from the default Rails stack. So how do you build a form where the user adds and removes child records before saving the parent in Rails 8?

It's actually simpler than it used to be. accepts_nested_attributes_for still works exactly the way it always has, and the client-side piece Cocoon used to handle is a small Stimulus controller.

The Model

Nothing new here. A parent has_many children and declares nested attributes:

# app/models/project.rb
class Project < ApplicationRecord
  has_many :tasks, dependent: :destroy
  accepts_nested_attributes_for :tasks, allow_destroy: true, reject_if: :all_blank
end

allow_destroy: true lets you mark rows for deletion with a _destroy flag. reject_if: :all_blank skips rows where every field is empty, so clicking "Add" and leaving it alone doesn't save a blank task.

Strong Parameters

# app/controllers/projects_controller.rb
def project_params
  params.expect(project: [:name, tasks_attributes: [[:id, :title, :done, :_destroy]]])
end

The :id key matters: without it, Rails treats every row as new on update and you end up with duplicates instead of edits.

The Form

Render the form with a hidden <template> containing one blank row. The child_index: "NEW_RECORD" placeholder is what Stimulus swaps out with a unique index every time the user clicks "Add":

-# app/views/projects/new.html.haml
= form_with(model: @project, data: { controller: "nested-form" }) do |f|
  = f.text_field :name

  %div{data: { nested_form_target: "list" }}
    = f.fields_for :tasks do |task_fields|
      = render "task_fields", f: task_fields

  %template{data: { nested_form_target: "template" }}
    = f.fields_for :tasks, Task.new, child_index: "NEW_RECORD" do |task_fields|
      = render "task_fields", f: task_fields

  %button{type: "button", data: { action: "nested-form#add" }} Add task
  = f.submit

The row partial at _task_fields.html.haml includes a hidden _destroy input and a remove button:

-# app/views/projects/_task_fields.html.haml
%div{data: { nested_form_target: "row", persisted: (true if f.object.persisted?) }}
  = f.text_field :title
  = f.check_box :done
  = f.hidden_field :_destroy, data: { nested_form_target: "destroy" }
  %button{type: "button", data: { action: "nested-form#remove" }} Remove

The Stimulus Controller

// app/javascript/controllers/nested_form_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["list", "template"]

  add() {
    const html = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, new Date().getTime())
    this.listTarget.insertAdjacentHTML("beforeend", html)
  }

  remove(event) {
    const row = event.target.closest("[data-nested-form-target='row']")
    const destroy = row.querySelector("[data-nested-form-target='destroy']")

    if(row.hasAttribute("data-persisted")) {
      destroy.value = "1"
      row.hidden = true
    } else {
      row.remove()
    }
  }
}

The data-persisted attribute on the row partial differentiates new tasks from existing ones. For new ones, we simply need to remove them from the DOM. For tasks that already exist, we need to pass _destroy=1 and then hide it.

That's It

No Cocoon, no gem, no jQuery. Rails handles the nested save the way it always has, and the Stimulus controller is small enough that you can paste it into every project that needs it. If you don't want to roll your own, stimulus-rails-nested-form is basically this controller packaged up with a couple of extras.

What About an Alternative?

Nested forms are the right tool when you want to save the parent and child objects together. However, if you can save the parent first, check out Turbo Streams Instead of Nested Forms as a cool alternative.