Ruby on Rails Tutorials Nested Forms

Mastering Nested Forms in Ruby on Rails: A Complete Guide

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