Mastering Nested Forms in Ruby on Rails: A Complete Guide
Nested forms are one of the most challenging aspects of Rails development. While accepts_nested_attributes_for
provides a foundation, real-world applications require much more sophisticated handling. In this guide, we'll explore advanced patterns and solutions for complex nested form scenarios.
Understanding the Basics: Beyond accepts_nested_attributes_for
First, let's look at a proper setup for nested forms:
# app/models/company.rb
class Company < ApplicationRecord
has_many :departments
has_many :employees, through: :departments
# ❌ Bad: Basic nested attributes
accepts_nested_attributes_for :departments
# ✅ Good: Nested attributes with validation and destruction
accepts_nested_attributes_for :departments,
allow_destroy: true,
reject_if: :all_blank,
limit: 10 # Prevent DOS attacks
# Add custom validation for nested attributes
validate :departments_limit
private
def departments_limit
if departments.size > 10
errors.add(:base, 'Maximum 10 departments allowed')
end
end
end
# app/models/department.rb
class Department < ApplicationRecord
belongs_to :company
has_many :employees
accepts_nested_attributes_for :employees,
allow_destroy: true,
reject_if: ->(attrs) { attrs['name'].blank? && attrs['role'].blank? }
validates :name, presence: true
end
Form Objects: A Better Way to Handle Complex Forms
For complex nested forms, using a dedicated Form Object provides better organization and validation:
# app/forms/company_registration_form.rb
class CompanyRegistrationForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :company_name, :string
attribute :departments_attributes, array: true, default: []
validates :company_name, presence: true
validate :validate_departments
# Define accessors for nested form fields
def departments
@departments ||= departments_attributes.map do |attrs|
DepartmentForm.new(attrs)
end
end
def save
return false unless valid?
ActiveRecord::Base.transaction do
create_company_with_departments
end
true
rescue ActiveRecord::RecordInvalid => e
errors.add(:base, e.message)
false
end
private
def create_company_with_departments
@company = Company.create!(name: company_name)
departments.each do |dept_form|
department = @company.departments.create!(
name: dept_form.name,
budget: dept_form.budget
)
dept_form.employees.each do |emp_form|
department.employees.create!(
name: emp_form.name,
role: emp_form.role,
salary: emp_form.salary
)
end
end
end
def validate_departments
return if departments.all?(&:valid?)
errors.add(:base, 'Invalid department data')
end
end
# app/forms/department_form.rb
class DepartmentForm
include ActiveModel::Model
attr_accessor :name, :budget, :employees_attributes
validates :name, presence: true
validates :budget, numericality: { greater_than: 0 }
def employees
@employees ||= employees_attributes.map do |attrs|
EmployeeForm.new(attrs)
end
end
end
# app/forms/employee_form.rb
class EmployeeForm
include ActiveModel::Model
attr_accessor :name, :role, :salary
validates :name, :role, presence: true
validates :salary, numericality: { greater_than: 0 }
end
The Controller: Clean and Simple
Using Form Objects simplifies our controller:
# app/controllers/companies_controller.rb
class CompaniesController < ApplicationController
def new
@form = CompanyRegistrationForm.new
# Initialize with empty department and employee
@form.departments_attributes = [{ employees_attributes: [{}] }]
end
def create
@form = CompanyRegistrationForm.new(company_params)
if @form.save
redirect_to @form.company, notice: 'Company created successfully'
else
render :new, status: :unprocessable_entity
end
end
private
def company_params
params.require(:company_registration_form).permit(
:company_name,
departments_attributes: [
:name,
:budget,
employees_attributes: [:name, :role, :salary]
]
)
end
end
The View: Dynamic Nested Forms with JavaScript
Here's how to implement dynamic nested forms using Stimulus:
# app/views/companies/new.html.erb
<%= form_with model: @form, url: companies_path do |f| %>
<%= f.text_field :company_name %>
<div data-controller="nested-form">
<template data-nested-form-target="template">
<%= f.fields_for :departments, DepartmentForm.new, child_index: 'NEW_RECORD' do |dept| %>
<%= render 'department_fields', f: dept %>
<% end %>
</template>
<div data-nested-form-target="forms">
<%= f.fields_for :departments do |dept| %>
<%= render 'department_fields', f: dept %>
<% end %>
</div>
<button type="button" data-action="nested-form#add">
Add Department
</button>
</div>
<% end %>
# app/views/companies/_department_fields.html.erb
<div class="nested-fields" data-controller="nested-form">
<%= f.text_field :name %>
<%= f.number_field :budget %>
<div data-nested-form-target="forms">
<%= f.fields_for :employees do |employee| %>
<%= render 'employee_fields', f: employee %>
<% end %>
</div>
<button type="button" data-action="nested-form#add">
Add Employee
</button>
<button type="button" data-action="nested-form#remove">
Remove Department
</button>
</div>
Stimulus Controller for Dynamic Forms
// app/javascript/controllers/nested_form_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["template", "forms"];
add(event) {
event.preventDefault();
const content = this.templateTarget.innerHTML.replace(
/NEW_RECORD/g,
new Date().getTime()
);
this.formsTarget.insertAdjacentHTML("beforeend", content);
}
remove(event) {
event.preventDefault();
const wrapper = event.target.closest(".nested-fields");
if (wrapper.dataset.newRecord === "true") {
wrapper.remove();
} else {
wrapper.style.display = "none";
wrapper.querySelector("input[name*='_destroy']").value = "1";
}
}
}
Common Gotchas and Solutions
1. Memory Leaks with Large Forms
# ❌ Bad: Loading all associations
def new
@company = Company.includes(:departments, departments: :employees).find(params[:id])
end
# ✅ Good: Limiting the data loaded
def new
@company = Company.includes(
departments: { limit: 10 },
employees: { limit: 50 }
).find(params[:id])
end
2. Mass Assignment Protection
# ❌ Bad: Permitting all nested attributes
def company_params
params.require(:company).permit!
end
# ✅ Good: Explicitly permitting attributes
def company_params
params.require(:company).permit(
:name,
departments_attributes: [
:id,
:name,
:budget,
:_destroy,
employees_attributes: [:id, :name, :role, :salary, :_destroy]
]
)
end
3. Validation Errors Display
# app/helpers/form_helper.rb
module FormHelper
def nested_form_errors(form, field)
return unless form.object.errors[field].any?
content_tag :div, class: 'error-messages' do
form.object.errors[field].map do |message|
content_tag(:p, message, class: 'error-message')
end.join.html_safe
end
end
end
# In your view
<%= nested_form_errors(f, :name) %>
4. Handling File Uploads in Nested Forms
class CompanyRegistrationForm
include ActiveModel::Model
attr_accessor :logo, :department_documents
def save
return false unless valid?
ActiveRecord::Base.transaction do
@company = Company.new(company_attributes)
@company.logo.attach(logo) if logo.present?
departments.each do |dept_form|
department = @company.departments.build(dept_form.attributes)
# Handle nested file uploads
if dept_form.documents.present?
dept_form.documents.each do |doc|
department.documents.attach(doc)
end
end
end
@company.save!
end
true
end
end
5. Performance Optimization
class CompanyRegistrationForm
def save
return false unless valid?
ActiveRecord::Base.transaction do
# Bulk insert departments
department_attributes = departments.map(&:attributes)
created_departments = Department.insert_all!(
department_attributes,
returning: [:id]
)
# Bulk insert employees
employee_attributes = departments.flat_map do |dept|
dept.employees.map do |emp|
emp.attributes.merge(
department_id: created_departments[dept.index]['id']
)
end
end
Employee.insert_all!(employee_attributes)
end
end
end
Testing Nested Forms
RSpec.describe CompanyRegistrationForm do
describe '#save' do
let(:valid_attributes) do
{
company_name: 'Acme Corp',
departments_attributes: [
{
name: 'Engineering',
budget: 100_000,
employees_attributes: [
{
name: 'John Doe',
role: 'Developer',
salary: 80_000
}
]
}
]
}
end
it 'creates company with nested departments and employees' do
form = described_class.new(valid_attributes)
expect { form.save }.to change {
Company.count
}.by(1).and change {
Department.count
}.by(1).and change {
Employee.count
}.by(1)
company = Company.last
expect(company.name).to eq('Acme Corp')
expect(company.departments.first.name).to eq('Engineering')
expect(company.departments.first.employees.first.name).to eq('John Doe')
end
context 'with invalid nested attributes' do
let(:invalid_attributes) do
valid_attributes.deep_merge(
departments_attributes: [
{ name: '', budget: -1000 }
]
)
end
it 'does not create any records' do
form = described_class.new(invalid_attributes)
expect { form.save }.not_to change { Company.count }
expect(form.errors[:base]).to include('Invalid department data')
end
end
end
end
Remember to:
- Use Form Objects for complex forms
- Implement proper validation at all levels
- Handle file uploads carefully
- Consider performance with bulk operations
- Test thoroughly, including error cases