The project is in a healthy, maintained state
A modern, Stimulus-based replacement for Cocoon. Dynamically add and remove nested form fields with full Turbo compatibility and zero jQuery dependency.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 7.0
 Project Readme

HotwireNestedForm

Gem Version CI

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:install

Quick 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
end

2. Controller Setup

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

3. 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 min

Limit 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 sortablejs

2. 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) }
end

3. 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 --animations

Rails (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
end

2. 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]])
end

Each 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" and aria-label on 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-stimulus

Register 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">&times;</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

  1. Replace gem in Gemfile:

    # Remove: gem "cocoon"
    gem "hotwire_nested_form"
  2. Run installer:

    bundle install
    rails generate hotwire_nested_form:install
  3. Add data-controller="nested-form" to your form wrapper:

    <div data-controller="nested-form">
      <!-- your fields_for and links here -->
    </div>
  4. 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
  5. 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 rspec

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/bhumit4220/hotwire_nested_form.

License

MIT License. See LICENSE for details.