HotwireNestedForm
A modern, Stimulus-based gem for dynamic nested forms in Rails. Drop-in replacement for Cocoon with zero jQuery dependency and full Turbo compatibility.
Why HotwireNestedForm?
| Feature | Cocoon | HotwireNestedForm |
|---|---|---|
| jQuery required | Yes | No |
| Turbo compatible | No | Yes |
preventDefault() works |
No | Yes |
| Maintained | No (since 2020) | Yes |
| Rails 7+ support | Partial | Full |
Installation
Add to your Gemfile:
gem "hotwire_nested_form"Run the installer:
bundle install
rails generate hotwire_nested_form:installQuick Start
1. Model Setup
# app/models/project.rb
class Project < ApplicationRecord
has_many :tasks, dependent: :destroy
accepts_nested_attributes_for :tasks, allow_destroy: true, reject_if: :all_blank
end2. Controller Setup
# app/controllers/projects_controller.rb
def project_params
params.require(:project).permit(:name, tasks_attributes: [:id, :name, :_destroy])
end3. Form Setup
<%# app/views/projects/_form.html.erb %>
<%= form_with model: @project do |f| %>
<%= f.text_field :name %>
<div data-controller="nested-form">
<div id="tasks">
<%= f.fields_for :tasks do |task_form| %>
<%= render "task_fields", f: task_form %>
<% end %>
</div>
<%= link_to_add_association "Add Task", f, :tasks %>
</div>
<%= f.submit %>
<% end %>4. Fields Partial
<%# app/views/projects/_task_fields.html.erb %>
<div class="nested-fields">
<%= f.text_field :name, placeholder: "Task name" %>
<%= link_to_remove_association "Remove", f %>
</div>That's it! Click "Add Task" to add fields, "Remove" to remove them.
SimpleForm Support
Works automatically with SimpleForm! No configuration needed.
<%= simple_form_for @project do |f| %>
<%= f.input :name %>
<div data-controller="nested-form">
<%= f.simple_fields_for :tasks do |task_form| %>
<%= render "task_fields", f: task_form %>
<% end %>
<%= link_to_add_association "Add Task", f, :tasks %>
</div>
<%= f.button :submit %>
<% end %>Formtastic Support
Works automatically with Formtastic! No configuration needed.
<%= semantic_form_for @project do |f| %>
<%= f.input :name %>
<div data-controller="nested-form">
<%= f.semantic_fields_for :tasks do |task_form| %>
<%= render "task_fields", f: task_form %>
<% end %>
<%= link_to_add_association "Add Task", f, :tasks %>
</div>
<%= f.actions %>
<% end %>Min/Max Limits
Control the number of nested items with data attributes:
<div data-controller="nested-form"
data-nested-form-min-value="1"
data-nested-form-max-value="5"
data-nested-form-limit-behavior-value="disable">
<%= f.fields_for :tasks do |tf| %>
<%= render "task_fields", f: tf %>
<% end %>
<%= link_to_add_association "Add Task", f, :tasks %>
</div>Limit Options
| Attribute | Type | Default | Description |
|---|---|---|---|
data-nested-form-min-value |
Integer | 0 |
Minimum items required |
data-nested-form-max-value |
Integer | unlimited | Maximum items allowed |
data-nested-form-limit-behavior-value |
String | "disable" |
"disable", "hide", or "error"
|
Limit Behaviors
| Behavior | At Max Limit | At Min Limit |
|---|---|---|
disable |
Add button disabled | Remove buttons disabled |
hide |
Add button hidden | Remove buttons hidden |
error |
Event fires, button enabled | Event fires, button enabled |
Dynamic Limits
Change limits at runtime via JavaScript:
const form = document.querySelector('[data-controller="nested-form"]')
form.dataset.nestedFormMaxValue = 10 // Change max
form.dataset.nestedFormMinValue = 2 // Change minLimit Events
document.addEventListener("nested-form:limit-reached", (event) => {
alert(`Maximum ${event.detail.limit} items allowed`)
})
document.addEventListener("nested-form:minimum-reached", (event) => {
alert(`Must keep at least ${event.detail.minimum} items`)
})Drag & Drop Sorting
Enable drag & drop reordering with position persistence:
1. Install SortableJS
# Rails with importmap
bin/importmap pin sortablejs
# OR npm/yarn
npm install sortablejs2. Add Position to Your Model
rails generate migration AddPositionToTasks position:integer
rails db:migrate# app/models/task.rb
class Task < ApplicationRecord
belongs_to :project
default_scope { order(:position) }
end3. Update Your Partial
<%# app/views/projects/_task_fields.html.erb %>
<div class="nested-fields">
<%= f.hidden_field :position %>
<span class="drag-handle">☰</span>
<%= f.text_field :name %>
<%= link_to_remove_association "Remove", f %>
</div>4. Enable Sorting
<div data-controller="nested-form"
data-nested-form-sortable-value="true"
data-nested-form-sort-handle-value=".drag-handle">
<!-- nested fields -->
</div>5. Permit Position in Controller
params.require(:project).permit(:name,
tasks_attributes: [:id, :name, :position, :_destroy])Sorting Options
| Attribute | Default | Description |
|---|---|---|
data-nested-form-sortable-value |
false |
Enable drag & drop |
data-nested-form-position-field-value |
"position" |
Position field name |
data-nested-form-sort-handle-value |
(none) | Drag handle selector |
Sorting Events
| Event | Detail | Description |
|---|---|---|
nested-form:before-sort |
{ item, oldIndex } |
Before drag (cancelable) |
nested-form:after-sort |
{ item, oldIndex, newIndex } |
After drop |
Example CSS
.drag-handle {
cursor: grab;
user-select: none;
}
.nested-form-dragging {
opacity: 0.8;
background: #e3f2fd;
}
.nested-form-drag-ghost {
opacity: 0.4;
border: 2px dashed #2196F3;
}Animations
Add smooth CSS transitions when items are added or removed:
<div data-controller="nested-form"
data-nested-form-animation-value="fade"
data-nested-form-animation-duration-value="300">
<!-- nested fields -->
</div>Include the Animation Stylesheet
Rails (generator):
rails g hotwire_nested_form:install --animationsRails (manual): Add to your stylesheet:
@import "hotwire_nested_form/animations";NPM:
import "hotwire-nested-form-stimulus/css/animations.css"Animation Options
| Attribute | Default | Description |
|---|---|---|
data-nested-form-animation-value |
"" |
"fade", "slide", or "" (none) |
data-nested-form-animation-duration-value |
300 |
Duration in milliseconds |
CSS Classes
| Class | When Applied |
|---|---|
nested-form-enter |
Immediately on add |
nested-form-enter-active |
Next frame after add (triggers transition) |
nested-form-exit-active |
On remove (triggers transition, then element is hidden/removed) |
You can customize the animations by overriding these classes in your stylesheet.
Deep Nesting (Multi-Level)
Nest forms inside forms (e.g. Project -> Tasks -> Subtasks):
1. Model Setup
class Project < ApplicationRecord
has_many :tasks, dependent: :destroy
accepts_nested_attributes_for :tasks, allow_destroy: true
end
class Task < ApplicationRecord
belongs_to :project
has_many :subtasks, dependent: :destroy
accepts_nested_attributes_for :subtasks, allow_destroy: true
end2. Form Setup
<%# _form.html.erb %>
<%= form_with model: @project do |f| %>
<div data-controller="nested-form">
<div id="tasks">
<%= f.fields_for :tasks do |tf| %>
<%= render "task_fields", f: tf %>
<% end %>
</div>
<%= link_to_add_association "Add Task", f, :tasks,
insertion: :append, target: "#tasks" %>
</div>
<%= f.submit %>
<% end %>
<%# _task_fields.html.erb %>
<div class="nested-fields">
<%= f.text_field :name %>
<%= link_to_remove_association "Remove Task", f %>
<div data-controller="nested-form">
<div id="subtasks">
<%= f.fields_for :subtasks do |sf| %>
<%= render "subtask_fields", f: sf %>
<% end %>
</div>
<%= link_to_add_association "Add Subtask", f, :subtasks,
insertion: :append, target: "#subtasks" %>
</div>
</div>
<%# _subtask_fields.html.erb %>
<div class="nested-fields">
<%= f.text_field :name %>
<%= link_to_remove_association "Remove", f %>
</div>3. Controller Params
def project_params
params.require(:project).permit(:name,
tasks_attributes: [:id, :name, :_destroy,
subtasks_attributes: [:id, :name, :_destroy]])
endEach nesting level automatically gets a unique placeholder (NEW_TASK_RECORD, NEW_SUBTASK_RECORD) so adding items at one level doesn't affect templates at other levels. Each data-controller="nested-form" operates independently.
Accessibility
Accessibility features are enabled by default. The controller automatically:
- Sets
role="group"andaria-labelon the controller element - Creates an
aria-live="polite"region for screen reader announcements - Focuses the first input when a new item is added
- Moves focus to the "Add" button when an item is removed
- Announces add/remove/duplicate actions to screen readers
Disabling Accessibility
<div data-controller="nested-form"
data-nested-form-a11y-value="false">
<!-- fields -->
</div>Custom ARIA Label
Set your own aria-label and the controller will preserve it:
<div data-controller="nested-form"
aria-label="Project tasks">
<!-- fields -->
</div>Duplicate/Clone
Duplicate an existing nested item (with its field values) as a starting point for a new item:
1. Add Duplicate Button to Partial
<%# _task_fields.html.erb %>
<div class="nested-fields">
<%= f.text_field :name %>
<%= link_to_duplicate_association "Duplicate", f, class: "btn-sm" %>
<%= link_to_remove_association "Remove", f %>
</div>2. That's It!
Clicking "Duplicate" will:
- Clone the item with all current field values
- Generate a new unique index (so Rails creates a new record)
- Remove the persisted record ID (so it saves as a new record)
- Respect the max limit
- Animate the new item if animations are enabled
- Focus the first input of the clone
- Announce "Item duplicated." to screen readers
Duplicate Events
| Event | Cancelable | Detail |
|---|---|---|
nested-form:before-duplicate |
Yes | { source } |
nested-form:after-duplicate |
No | { source, clone } |
// Customize the clone before it's inserted
document.addEventListener("nested-form:after-duplicate", (event) => {
const clone = event.detail.clone
// Clear specific fields in the clone
clone.querySelector("input[name*='description']").value = ""
})
// Prevent duplication conditionally
document.addEventListener("nested-form:before-duplicate", (event) => {
if (someCondition) event.preventDefault()
})NPM Package (JavaScript-only)
For non-Rails projects using Stimulus, install via npm:
npm install hotwire-nested-form-stimulusRegister the controller:
import { Application } from "@hotwired/stimulus"
import NestedFormController from "hotwire-nested-form-stimulus"
const application = Application.start()
application.register("nested-form", NestedFormController)See NPM package documentation for full details.
API Reference
link_to_add_association
link_to_add_association(name, form, association, options = {}, &block)| Option | Type | Default | Description |
|---|---|---|---|
:partial |
String | "#{assoc}_fields" |
Custom partial path |
:count |
Integer | 1 |
Fields to add per click |
:insertion |
Symbol | :before |
:before, :after, :append, :prepend
|
:target |
String | nil |
CSS selector for insertion target |
:wrap_object |
Proc | nil |
Wrap new object (for decorators) |
:render_options |
Hash | {} |
Options passed to render
|
Examples:
<%# Basic usage %>
<%= link_to_add_association "Add Task", f, :tasks %>
<%# With custom partial %>
<%= link_to_add_association "Add Task", f, :tasks,
partial: "projects/custom_task_fields" %>
<%# With block for custom content %>
<%= link_to_add_association f, :tasks do %>
<span class="icon">+</span> Add Task
<% end %>
<%# With HTML classes %>
<%= link_to_add_association "Add Task", f, :tasks,
class: "btn btn-primary" %>
<%# Add multiple at once %>
<%= link_to_add_association "Add 3 Tasks", f, :tasks, count: 3 %>link_to_remove_association
link_to_remove_association(name, form, options = {}, &block)| Option | Type | Default | Description |
|---|---|---|---|
:wrapper_class |
String | "nested-fields" |
Class of wrapper to remove |
Examples:
<%# Basic usage %>
<%= link_to_remove_association "Remove", f %>
<%# With custom wrapper class %>
<%= link_to_remove_association "Remove", f,
wrapper_class: "task-item" %>
<%# With block %>
<%= link_to_remove_association f do %>
<span class="icon">×</span> Remove
<% end %>link_to_duplicate_association
link_to_duplicate_association(name, form, options = {}, &block)| Option | Type | Default | Description |
|---|---|---|---|
| (standard HTML options) | Passed to the <a> tag |
Examples:
<%# Basic usage %>
<%= link_to_duplicate_association "Duplicate", f %>
<%# With HTML classes %>
<%= link_to_duplicate_association "Duplicate", f, class: "btn btn-sm" %>
<%# With block %>
<%= link_to_duplicate_association f do %>
<span class="icon">📋</span> Copy
<% end %>JavaScript Events
| Event | Cancelable | Detail | When |
|---|---|---|---|
nested-form:before-add |
Yes | { wrapper } |
Before adding fields |
nested-form:after-add |
No | { wrapper } |
After fields added |
nested-form:before-remove |
Yes | { wrapper } |
Before removing fields |
nested-form:after-remove |
No | { wrapper } |
After fields removed |
nested-form:limit-reached |
No | { limit, current } |
When max limit reached |
nested-form:minimum-reached |
No | { minimum, current } |
When min limit reached |
nested-form:before-sort |
Yes | { item, oldIndex } |
Before drag starts |
nested-form:after-sort |
No | { item, oldIndex, newIndex } |
After drop completes |
nested-form:before-duplicate |
Yes | { source } |
Before duplicating item |
nested-form:after-duplicate |
No | { source, clone } |
After item duplicated |
Usage Examples:
// Prevent adding if limit reached
document.addEventListener("nested-form:before-add", (event) => {
const taskCount = document.querySelectorAll(".nested-fields").length
if (taskCount >= 10) {
event.preventDefault()
alert("Maximum 10 tasks allowed")
}
})
// Initialize plugins on new fields
document.addEventListener("nested-form:after-add", (event) => {
const wrapper = event.detail.wrapper
// Initialize datepicker, select2, etc.
})
// Confirm before removing
document.addEventListener("nested-form:before-remove", (event) => {
if (!confirm("Are you sure?")) {
event.preventDefault()
}
})
// Update totals after removal
document.addEventListener("nested-form:after-remove", (event) => {
updateTaskCount()
})Migrating from Cocoon
-
Replace gem in Gemfile:
# Remove: gem "cocoon" gem "hotwire_nested_form"
-
Run installer:
bundle install rails generate hotwire_nested_form:install
-
Add
data-controller="nested-form"to your form wrapper:<div data-controller="nested-form"> <!-- your fields_for and links here --> </div>
-
Update event listeners (optional):
// Before: cocoon:before-insert // After: nested-form:before-add // Before: cocoon:after-insert // After: nested-form:after-add // Before: cocoon:before-remove // After: nested-form:before-remove // Before: cocoon:after-remove // After: nested-form:after-remove
-
Remove jQuery if no longer needed.
Requirements
- Ruby 3.1+
- Rails 7.0+ (including Rails 8)
- Stimulus (included in Rails 7+ by default)
Development
After checking out the repo, run:
bundle install
bundle exec rspecContributing
Bug reports and pull requests are welcome on GitHub at https://github.com/bhumit4220/hotwire_nested_form.
License
MIT License. See LICENSE for details.