<div data-controller="nested-fields">
<template data-nested-fields-target="template">
<%= form.fields_for :links, Link.new, child_index: 'NEW_RECORD' do |lf| %>
<div class="flex gap-2">
<%= lf.text_field :url, class: 'flex-1 rounded border p-2' %>
<%= lf.hidden_field :_destroy, value: '0', data: { nested_fields_target: 'destroy' } %>
<button type="button" data-action="nested-fields#remove" class="text-red-700">Remove</button>
</div>
<% end %>
</template>
<div data-nested-fields-target="container">
<%= form.fields_for :links do |lf| %>
<div class="flex gap-2">
<%= lf.text_field :url, class: 'flex-1 rounded border p-2' %>
<%= lf.hidden_field :_destroy, value: lf.object._destroy, data: { nested_fields_target: 'destroy' } %>
<button type="button" data-action="nested-fields#remove" class="text-red-700">Remove</button>
</div>
<% end %>
</div>
<button type="button" data-action="nested-fields#add" class="mt-2 rounded bg-gray-100 px-3 py-1">Add link</button>
</div>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["template", "container"]
add() {
const html = this.templateTarget.innerHTML.replaceAll("NEW_RECORD", Date.now())
this.containerTarget.insertAdjacentHTML("beforeend", html)
}
remove(event) {
const wrapper = event.currentTarget.closest("div")
const destroy = wrapper.querySelector("input[name*='[_destroy]']")
if (destroy) {
destroy.value = "1"
wrapper.style.display = "none"
} else {
wrapper.remove()
}
}
}
Nested fields are a common Rails pain point, and the old solution was heavy JS gems. With Stimulus, I keep it minimal: render a hidden <template> containing the fields with a placeholder index, then on “Add” clone it and swap the placeholder for a timestamp. Removing fields either deletes the node (new records) or sets the _destroy hidden input (existing records). This works nicely with Turbo because the controller is attached to the form and survives replacements. It’s also easy to unit test in the browser: click add, ensure the inputs exist. The key is being consistent with naming and using fields_for to generate correct param structure.