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:
#assign_attributes
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.
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 %>
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)
.
#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 MethodsHere’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#address
and 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=
Without Person#address
, Rails will raise an ActionView::Template::Error (undefined local variable or method address for <Person ...>)
. This method enables the form to access the address object to build its fields (e.g., :street
and :zip_code
) and to access any validation errors via address.errors
. In most cases, this method is already defined on the Person class.
Without Person#address_attributes=
, Rails treats :address
as just another attribute like :name
on a Person
object. This results in a different payload structure, such as params.require(:person).permit(:name, address: [])
. While it's possible to make this scenario work, the Person#address=
setter likely expects an Address
object, and not address attributes as arguments.
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 🤯
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
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.
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>
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 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
.
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.
_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.
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!