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
endallow_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]]])
endThe :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.submitThe 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" }} RemoveThe 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.