Perfecting Your Rails Form
Part 2: Nested Attributes

In this series, we’ll take a deep dive into how to make our Rails forms more powerful and user-friendly. The series will include:

  1. Attribute Accessors and #assign_attributes
  2. Nested Attributes: One-to-One and One-to-Many Associations
  3. Dynamic Forms with StimulusJS
  4. Customising Forms with Form builders

If you’re interested in exploring these techniques at your own pace, check out railsamples.com, a site I created to curate form examples for common web scenarios. You’ll find single-file Rails apps that demonstrate each technique with minimal setup

The concepts in this section are detailed in the fields_for and Active Record Nested Attributes API documentation online. I highly recommend reading through them for additional context.

What Are Nested Attributes?

Nested attributes allow you to assign attributes that build, create, update or destroy associated objects directly from the main form model. Throughout the article we'll use the same example, a Person, with a name, has an associated Address, which consists of a street and a zip_code. The Person object is responsible for updating its Address.

class Person < ActiveRecord::Base
  has_one :address
  accepts_nested_attributes_for :address, update_only: true
  validates :name, presence: true

  delegate :zip_code, :street, to: :address, prefix: true
end

class Address < ActiveRecord::Base
  belongs_to :person
  validates :zip_code, presence: true
end
<%= form_with model: @person do |f| %>
  <%= f.text_field :name %>

  <%= f.fields_for :address do |ff| %>
    <%= ff.text_field :street %>
    <%= ff.text_field :zip_code %>
  <% end %>

  <%= f.submit %>
<% end %>

Why Are Nested Attributes So Complex?

In my opinion, what makes nested attributes so complex is that developers often fixate on the common use case involving ActiveRecord and defining #accepts_nested_attributes_for on an association. This can be misleading!

Once we understand the nested conventions surrounding forms, models and controllers, nested attributes can also be implemented with Active Models. As the API documentation for #fields_for explains:

Nested attribute writers are normal setter methods named after an association. The most common way of defining these writers is either with accepts_nested_attributes_for in a model definition or by defining a method with the proper name. For example: the attribute writer for the association :address is called address_attributes=.

Alex, why does it matter?

It matters because the form helper method fields_for(:address) specifically looks for a method named #address_attributes= — not #accepts_nested_attributes_for(:address).

You Don't Need #accepts_nested_attributes_for

See, #accepts_nested_attributes_for method is part of Rails’ DSL, similar to methods like #validates or #has_one. These methods act as shortcuts, defining a set of features on a model. Specifically, #accepts_nested_attributes_for will, among other things, define the accessor #address_attributes= expected by the form method field_for(:address). See the usage and definition of #generate_association_writer in the Rails codebase.

With this understanding, we could (but shouldn't) define nested attributes from scratch on ActiveRecord objects and we can certainly emulate #accepts_nested_attributes_for with Active Models by creating our own #address_attributes= method.

class Person
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name
  validates :name, presence: true

  delegate :zip_code, :street, to: :address, prefix: true

  def address
    @address ||= Address.new
  end

  def address_attributes=(params)
    address.assign_attributes(params.merge(person: self))
  end
end

class Address
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :zip_code, :string
  attribute :street, :string
  attribute :person

  validates :zip_code, presence: true
end

class TestNestedAttributes < Minitest::Test
  def test_it_creates_an_address_from_user
    person = Person.new(name: 'Alex', address_attributes: { street: 'Simple street', zip_code: 9999 })

    assert_equal 'Alex', person.name
    assert_equal 'Simple street', person.address_street
    assert_equal '9999', person.address_zip_code
    assert_equal person, person.address.person
  end
end

#fields_for - We Only Need Two Methods

Here’s a standard approach to writing a form with nested attributes for a one-to-one association (labels omitted).

<%= form_with model: @person do |person_form| %>
  <%= person_form.text_field :name %>

  <%= person_form.fields_for :address do |address_fields| %>
    <%= address_fields.text_field :street %>
    <%= address_fields.text_field :zip_code %>
  <% end %>

  <%= person_form.submit %>
<% end %>

To make this work, only two methods need to be defined on the Person model: Person#addressand Person#address_attributes=. That's it. I repeat:

We only need two methods to define nested Address attributes on a Person form: Person#address and Person#address_attributes=

Here is what happens once the form above is submitted. First an HTTP request is sent with the following request payload.

person[name]: 'Alex'
person[address_attributes][street]: 'Simple street'
person[address_attributes][zip_code]: '9999'

Next, the controller action receives the submitted parameters, filters for valid ones, and formats them:

def person_params
  params.require(:person).permit(:name, address_attributes: [:street, :zip_code])
end

These parameters are then passed in the Person model.

@person = Person.new(person_params)
# Equivalent to
@person = Person.new(name: 'Alex', address_attributes: { street: 'Simple street', zip_code: '9999'})

At this point, we recognize (thanks to Part 1) that this process is exactly the same as below.

@person = Person.new
@person.name = 'Alex'
@person.address_attributes = { street: 'Simple street', zip_code: '9999' }

Which is similar to this:

@person = Person.new
@person.name = 'Alex'
@person.address = Address.new
@person.address.street = 'Simple street'
@person.address.zip_code = '9999'

That's it 🤯

What About One-to-Many Associations?

The same rules apply for enabling nested attributes in one-to-many associations. As the documentation notes:

Whether a one-to-one or one-to-many style form builder will be yielded depends on whether the normal reader method returns a single object or an array of objects.

This means you still only need two methods: an attribute reader Person#addresses and an accessor Person#addresses_attributes=. However, Person#addresses must return a collection object, like an ActiveRecord::Relation or an Array (other collection types are also supported). Here’s an example:

<%= form_with model: @person do |person_form| %>
  <%= person_form.text_field :name %>

  <%= person_form.fields_for :addresses do |address_fields| %>
    <%= address_fields.text_field :street %>
    <%= address_fields.text_field :zip_code %>
  <% end %>

  <%= person_form.submit %>
<% end %>

Here’s the resulting request payload for two addresses:

person[name]: 'Alex'
person[addresses_attributes][0][street]: 'Simple street'
person[addresses_attributes][0][zip_code]: '9999'
person[addresses_attributes][1][street]: 'Complex street'
person[addresses_attributes][1][zip_code]: '8888'

In this case, the nested attributes for the collection return an index or ID paired with the record attributes. Here is a simplified version of the resulting class with a set of two addresses.

class Person
  def addresses
    [address1, address2]
  end

  def addresses_attributes=(attributes)
    attributes.each do |index, address_attributes|
      addresses[index].assign_attributes(address_attributes)
    end
  end
end

The Sky Is the Limit

Using nested attributes with ActiveModel::Model opens up an untapped reservoir of opportunities for your users. As we explored in Part 1 - Attribute Accessors For The Win, forms don't need to map directly to the database columns of your ActiveRecord classes. Likewise, forms don’t have to be tied to ActiveRecord at all—and nested attributes are no exception.

Question: Are there user workflows in your software that could benefit from custom nested attributes using ActiveModel?

When using nested attributes with ActiveRecord, it's totally possible to override #address_attributes= to adapt your special business logic if needed.

class Person < ActiveRecord::Base
  has_one :address
  accepts_nested_attributes_for :address, update_only: true
  validates :name, presence: true

  def address_attributes=(params = {})
    super(params.merge('country' => 'New Zealand').tap do
      Rails.logger.info('Address changed through Person model')
    end
  end
end

Before doing so make sure to read fields_for and Active Record Nested Attributes documentation. You might still be reinventing the wheel.

For more examples of nested attributes, visit railsamples.com.

Form Validations

When a form is submitted with invalid inputs, it's generally re-rendered with objects that hold error states. Errors are often populated after calling #save or #valid?, which returns false when validation fails.

Errors added on @person will only wrap @person's inputs, such as f.text_field :name. To display errors on nested address fields (e.g., ff.text_field :street and ff.text_field :zip_code), we need to validate @person.address too. This validation is handled automatically with ActiveRecord since #accepts_nested_attributes_for defines autosave: true on the associated record. With ActiveModel, each associated record must be validated manually.

Since we pass the object to the form, the form builder will check for errors on each attribute submitted. By default, it wraps the field and label with <div class="field_with_erros"></div> when an error is present. Here are examples of the form rendered with and without errors.

# Form created with builder
<%= form_with model: @person do |f| %>
  <%= f.text_field :name %>

  <%= f.fields_for :address do |ff| %>
    <%= ff.text_field :street %>
    <%= ff.text_field :zip_code %>
  <% end %>

  <%= f.submit %>
<% end %>

# HTML rendered with no errors on @person object
<form action="/people" accept-charset="UTF-8" method="post">
  <input type="text" name="person[name]" id="person_name">
  <input type="text" name="person[address_attributes][street]" id="person_address_attributes_street">
  <input type="text" name="person[address_attributes][zip_code]" id="person_address_attributes_zip_code">
  <input type="submit" name="commit" value="Create Person" data-disable-with="Create Person">
</form>

# HTML rendered with errors on @person and person.address object
<form action="/people" accept-charset="UTF-8" method="post">
  <div class="field_with_errors">
    <input type="text" value="" name="person[name]" id="person_name">
  </div>

  <div class="field_with_errors">
    <input type="text" value="" name="person[address_attributes][street]" id="person_address_attributes_street">
  </div>

  <div class="field_with_errors">
    <input value="" type="text" name="person[address_attributes][zip_code]" id="person_address_attributes_zip_code">
  </div>

  <input type="submit" name="commit" value="Create Person" data-disable-with="Create Person">
</form>

Customize Form Errors

You can customize how errors are displayed within the form builder using config.field_error_proc. This configuration option accepts a proc that takes an html_tag (label/input) and the object instance as arguments. By default, this proc wraps the html_tag in a <div class="field_with_errors">, as shown in the block below.

config.field_error_proc = Proc.new do |html_tag, instance|
  content_tag :div, html_tag, class: "field_with_errors"
end

If your CSS framewok or styleguide requires a different structure to display form errors, updating this config might be just enough. If not stay tuned for part 4.

Persistence with Active Records

Persistence is a broad topic when working with nested attributes, and practice will likely unlock some "aha" moments along the way. Since Active Records are Active Models with a persistence layer, how are records updated or deleted?

In this section we will use #accepts_nested_attributes_for(:address) because there is no point reinventing what Rails has polished over the years. The API defines all the methods for proper nested attributes usage with ActiveRecord.

Update with IDs

When updating, Rails maps nested parameters to existing records based on their IDs. If an ID matches, the record is updated; if the ID is blank, a new record is created. This applies to both one-to-one and one-to-many associations. Often, these IDs are included as hidden form fields and must be permitted in your strong parameters.

<%= form_with model: @person do |f| %>
  <%= f.text_field :name %>

  <%= f.fields_for :address do |ff| %>
    <%= ff.hidden_field :id %>
    <%= ff.text_field :street %>
    <%= ff.text_field :zip_code %>
  <% end %>

  <%= f.submit %>
<% end %>

Note that, when using a one-to-one association, you can omit the ID field by setting the option accepts_nested_attributes_for :address, update_only: true. This tells Rails to create the associated record if none exists, and to update it if it’s already persisted.

Delete with _destroy and marked_for_destruction?

When using nested ActiveRecord in forms via #accepts_nested_attributes_for, you can pass an option :allow_destroy. This options enabled a private #_destroy parameter to be assigned per object’s attributes. The :id field must also be present to indentify persisted records.

_destroy acts as a shortcut to mark the record for destruction with #marked_for_destruction? method. Thus, it’s also possible to destroy a record by either overriding #marked_for_destruction? or calling #mark_for_destruction on the record. This technique is useful when customizing parameters in a form. Check out the full working example on railsamples.com (Nested Attributes - Custom mark_for_destruction) that uses a :selected checkbox instead of the standard :delete checkbox.

Part 2 summary

In this article, we aimed to demystify nested attributes. We explored the #fields_for helper method and what it requires to pass nested attributes as parameters to your controller. We saw that fields_for :addresses only requires two methods: a reader matching the argument Person#addresses and a writer for attributes Person#addresses_attributes=. As covered in Part 1, this method is simply another accessor that #assign_attributes uses to populate attributes on your object.

We also discovered that custom forms could be created without #accepts_nested_attributes_for. This method, however, handles much of the work by defining all necessary accessors and logic for updates and destroys when using ActiveRecord (not ActiveModel). Using #accepts_nested_attributes_for for ActiveRecord will simplify your workflow.

In the third part, we’ll explore how to use Stimulus JS to enhance the user experience and implement common web form use cases. Stay tuned!