In this article we are going to discuss how to build more complex forms using Rails’ nested attributes feature. I will show you how to manipulate multiple associated records from a single form and properly set up the models and controller to enable this feature. Also, we are going to discuss common pitfalls and power our form up to make it more dynamic using the gem called Cocoon. This solution allows adding and removing nested fields asynchronously while providing lots of customization options and callbacks.
The source code is available at GitHub.
The demo app is available at sitepoint-nested-forms.herokuapp.com.
Building a Simple Form
For this demo I’ll be using Rails 5 but most of the described concepts can be applied to Rails 3 and 4
Go ahead and create a new application without the default testing suite:
$ rails new NestedForms -T
Suppose that, with this app, we want to keep track of our favorite places and their addresses. For example, if we enter “Cafe” as a place along with a bunch of addresses of our preferred cafes. This means that one place may have many addresses, so we’ll describe it using associations:
$ rails g model Place title:string
$ rails g model Address city:string street:string place:belongs_to
$ rake db:migrate
Make sure that the associations are set up properly:
models/place.rb
[...]
has_many :addresses, dependent: :destroy
[...]
models/address.rb
[...]
belongs_to :place
[...]
Now code a basic PlacesController
(the one to rule them all…):
app/controllers/places_controller.rb
class PlacesController < ApplicationController
def index
@places = Place.all
end
def new
@place = Place.new
end
def create
@place = Place.new(place_params)
if @place.save
redirect_to root_path
else
render :new
end
end
private
def place_params
params.require(:place).permit(:title)
end
end
Add the routes:
config/routes.rb
[...]
resources :places, only: [:new, :create, :edit, :update]
root to: 'places#index'
[...]
Now, the view for the root page:
views/places/index.html.erb
<h1>Places</h1>
<p><%= link_to 'Add place', new_place_path %></p>
<ul><%= render @places %></ul>
Having added render @places
, we also need the corresponding partial:
views/places/_place.html.erb
<li>
<strong><%= place.title %></strong><br>
<% if place.addresses.any? %>
Addresses:
<ul>
<% place.addresses.each do |addr| %>
<li>
<%= addr.city %>, <%= addr.street %>
</li>
<% end %>
</ul>
<% end %>
</li>
The view to create places:
views/places/new.html.erb
<h1>Add place</h1>
<%= render 'form' %>
Including the form:
views/places/_form.html.erb
<%= render 'shared/errors', object: @place %>
<%= form_for @place do |f| %>
<div>
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<%= f.submit %>
<% end %>
Here’s yet another partial to display errors:
views/shared/_errors.html.erb
<% if object.errors.any? %>
<div>
<strong>
<%= pluralize(object.errors.count, 'error') %> were found
</strong>
<ul>
<% object.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
So far so good. However, in terms of better user experience, I’d like to allow adding place’s addresses on the same page rather than coding a separate form. This will also save us from coding an additional controller to manipulate addresses. That’s where the nested attributes come into play.
Adding Nested Attributes
The idea behind the nested attributes is quite simple. You have a single form where you can create an object along with its associated records. This feature can be added really fast, as it requires very small modifications to the controller and the model, as well as some markup.
It all starts with the addition of the long-named accepts_nested_attributes_for method:
models/places.rb
[...]
accepts_nested_attributes_for :addresses
[...]
Having added this method, we can now manipulate addresses via the places’ mass-assignment mechanism. The controller requires some changes as well:
places_controller.rb
[...]
private
def place_params
params.require(:place).permit(:title, addresses_attributes: [:id, :city, :street])
end
[...]
When you submit a form with the nested fields, the params[:place]
will contain an array under the key :addresses_attributes
. This array describes each address to be added into the database. As long as we are using strong_params, those new attributes have to be explicitly permitted.
Now add the nested form into the view:
views/places/_form.html.erb
<%= form_for @place do |f| %>
<%= render 'shared/errors', object: @place %>
<div>
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<div>
<p><strong>Addresses:</strong></p>
<%= f.fields_for :addresses do |address| %>
<div>
<%= address.label :city %>
<%= address.text_field :city %>
<%= address.label :street %>
<%= address.text_field :street %>
</div>
<% end %>
</div>
<%= f.submit %>
<% end %>
The fields_for method, as you’ve probably guessed, adds the nested fields. It is quite similar to the form_for
method but does not provide the form
tag itself. Note that inside the block I am using a new local variable address
– do not call it f
because it already contains the builder for the parent form.
There is a problem, however. When you visit the “New Place” page you won’t see any nested fields, because obviously the new instance of the Place
class does not contain any nested addresses. The simple fix, as suggested by the Rails docs, would be to build a couple of addresses directly in the controller:
places_controller.rb
[...]
def new
@place = Place.new
3.times { @place.addresses.build}
end
[...]
Indeed that’s not the best solution and we’ll get rid of it later.
You may now boot the server, navigate to the “New Place” page, and try creating a place with some nested addresses. However, things can’t always go that smooth, right? If you are using Rails 5.0, like me, you’ll see a pretty strange error “Addresses place must exist” preventing the form from being submitted. This appears to be a major bug in Rails 5 that is related to the new belongs_to_required_by_default
option set to true
. This setting means that the associated record must be present by default. To globally opt-out from this behaviour you may either set Rails.application.config.active_record.belongs_to_required_by_default
to false
(inside the new_framework_defaults.rb initializer file) or provide the optional: true
option to the belongs_to
method.
Another fix suggested here involves using the inverse_of
option:
models/place.rb
[...]
has_many :addresses, dependent: :destroy, inverse_of: :place
[...]
This bug should be fixed in Rails 5.1.
A Bit of Validation
Currently, a user may create a place with a list of empty addresses, which is probably not what you want. To control this behavior, use the reject_if
option that accepts either a lambda or the :all_blank
value. :all_blank
will reject a record where all the attributes are blank. However, in our case, we want to reject if any attribute is blank, so let’s stick with the lambda:
models/place.rb
[...]
accepts_nested_attributes_for :addresses,
reject_if: ->(attrs) { attrs['city'].blank? || attrs['street'].blank? }
[...]
Now any address without a city or street won’t be saved into the database.
Destroy ’em
The addresses can now be added, but there is no way to remove them later. To tackle this issue, supply yet another option to the accepts_nested_attributes_for
method:
models/place.rb
[...]
accepts_nested_attributes_for :addresses, allow_destroy: true,
reject_if: ->(attrs) { attrs['city'].blank? || attrs['street'].blank? }
[...]
This simply means that now nested records can be destroyed. In order to destroy a nested record, the _destroy
field has to be set with a truthy value (that is 1, ‘1’, true, or ‘true’). This new field has to be permitted as well:
places_controller.rb
[...]
private
def place_params
params.require(:place).permit(:title, addresses_attributes: [:id, :city, :street, :_destroy])
end
[...]
Add a checkbox to mark the nested records for deletion:
views/places/_form.html.erb
[...]
<div>
<p><strong>Addresses:</strong></p>
<%= f.fields_for :addresses do |address| %>
<div>
<%= address.label :city %>
<%= address.text_field :city %>
<%= address.label :street %>
<%= address.text_field :street %>
<%= address.check_box :_destroy %>
</div>
<% end %>
</div>
[...]
Now code two new controller’s actions:
places_controller.rb
[...]
def edit
@place = Place.find_by(id: params[:id])
end
def update
@place = Place.find_by(id: params[:id])
if @place.update_attributes(place_params)
redirect_to root_path
else
render :edit
end
end
[...]
Note that the actions themselves do not require any special changes, which is really great.
Add two more routes:
config/routes.rb
[...]
resources :places, only: [:new, :create, :edit, :update]
[...]
And present the “Edit” link:
views/places/_place.html.erb
<li>
<strong><%= place.title %></strong> | <%= link_to 'Edit place', edit_place_path(place) %><br>
[...]
</li>
Now open any existing place, set the checkboxes near the addresses you wish to destroy and submit the form!
Making It Dynamic
The basic nested form is complete, however, it is not very convenient to use. For example, there is no way to add more than three addresses. Adding this feature requires more work because Rails does not support adding fields dynamically out of the box. Luckily for us, there is already a solution available. It is called Cocoon and it is awesome. Cocoon powers nested forms with JavaScript, allowing files to be added or removed dynamically. Cocoon provides other customizations, as well. What’s more, it is form builder-agnostic and, therefore, works with either the basic Rails form builder or solutions like SimpleForm or Formtastic.
Getting started with Cocoon is simple. Add a new gem:
Gemfile
[...]
gem "cocoon"
[...]
And install it:
$ bundle install
Next, hook up a new JavaScript file:
javascripts/application.js
[...]
//= require cocoon
[...]
Note that Cocoon requires jQuery to be present. Now extract the nested fields into a separate partial:
views/places/_address_fields.html.erb
<div class="nested-fields">
<%= f.label :city %>
<%= f.text_field :city %>
<%= f.label :street %>
<%= f.text_field :street %>
<%= f.check_box :_destroy %>
<%= link_to_remove_association "remove address", f %>
</div>
Here we meet the first Cocoon’s helper – link_to_remove_association
. This helper, as the name implies, create a new link that asynchronously deletes an associated record. This method accepts three arguments (the third one is optional):
- The text to show in the link
- The form object
- HTML options (similar to the ones passed to the
link_to
)
Note that the nested-fields
class is required for the “remove address” link to work.
Now we need to use this partial inside the form:
views/places/_form.html.erb
<%= form_for @place do |f| %>
<%= render 'shared/errors', object: @place %>
<div>
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<div>
<p><strong>Addresses:</strong></p>
<div id="addresses">
<%= f.fields_for :addresses do |address| %>
<%= render 'address_fields', f: address %>
<% end %>
<div class="links">
<%= link_to_add_association 'add address', f, :addresses %>
</div>
</div>
</div>
<%= f.submit %>
<% end %>
Here we are using the second Cocoon’s helper – link_to_add_association. It renders a link to dynamically add nested fields using the partial we’ve coded a minute ago. This method accepts four parameters (the fourth one is optional):
- The text to show in the link
- The form builder (the parent’s form, not the nested’s one!)
- The name of the association
- HTML options. These options are similar to the ones the
link_to
accepts, however there are some special parameters available (like where to render the nested fields or which partial to use), so be sure to browse the docs
That’s pretty much it! Boot the server and try adding and removing places’ addresses. This is much convenient now, isn’t it?
Cocoon’s Callbacks
The last thing I am going to show you today is how to set up Cocoon’s callbacks. There are four of them:
cocoon:before-insert
cocoon:after-insert
cocoon:before-remove
cocoon:after-remove
With the cocoon:before-insert
you may animate the nested fields’ appearance. Let’s code this in a new CoffeeScript file:
javascripts/global.coffee
jQuery(document).on 'turbolinks:load', ->
addresses = $('#addresses')
addresses.on 'cocoon:before-insert', (e, el_to_add) ->
el_to_add.fadeIn(1000)
As long as I am using Turbolinks 5, we are listening to the turbolinks:load
event. If you prefer to stay away from Turbolinks for some reason, the first line will be much simpler:
javascripts/global.coffee
jQuery ->
Require this file:
javascripts/application.js
[...]
//= require global
[...]
Inside the cocoon:after-insert
callback you may, for example, highlight the added fields. The jQueryUI library has a bunch of effects to pick from – I am going to utilize the “Highlight” effect in this demo.
Add the new gem:
Gemfile
gem 'jquery-ui-rails'
Install it:
$ bundle install
Require a new JS file (note the proper order):
javascripts/application.js
//= require jquery
//= require jquery_ujs
//= require jquery-ui/effect-highlight
//= require cocoon
//= require global
//= require turbolinks
Now utilize this new effect:
javascripts/global.coffee
addresses.on 'cocoon:after-insert', (e, added_el) ->
added_el.effect('highlight', {}, 500)
To animate an element’s removal, use the cocoon:before-remove
callback. There is a small gotcha here, however. The actual removal of the element from the page has to be delayed because otherwise, we won’t be able to animate it.
javascripts/global.coffee
addresses.on 'cocoon:before-remove', (e, el_to_remove) ->
$(this).data('remove-timeout', 1000)
el_to_remove.fadeOut(1000)
$(this).data('remove-timeout', 1000)
says Cocoon to delay the element’s removal by 1 second – just enough for us to perform the animation.
Lastly, let’s display how many nested records were added and change that count dynamically. Add a new .count
block:
views/places/_form.html.erb
[...]
<div>
<p><strong>Addresses:</strong></p>
<div id="addresses">
<%= f.fields_for :addresses do |address| %>
<%= render 'address_fields', f: address %>
<% end %>
<div class="links">
<%= link_to_add_association 'add address', f, :addresses %>
</div>
<p class="count">Total: <span><%= @place.addresses.count %></span></p>
</div>
</div>
[...]
Next, wriate a simple recount
function that is going to change the counter:
javascripts/global.coffee
jQuery(document).on 'turbolinks:load', ->
addresses = $('#addresses')
count = addresses.find('.count > span')
recount = -> count.text addresses.find('.nested-fields').size()
[...]
Lastly, update the cocoon:after-insert
callback and add a new one called cocoon:after-remove
. The final version of the script is presented below:
javascripts/global.coffee
jQuery(document).on 'turbolinks:load', ->
addresses = $('#addresses')
count = addresses.find('.count > span')
recount = -> count.text addresses.find('.nested-fields').size()
addresses.on 'cocoon:before-insert', (e, el_to_add) ->
el_to_add.fadeIn(1000)
addresses.on 'cocoon:after-insert', (e, added_el) ->
added_el.effect('highlight', {}, 500)
recount()
addresses.on 'cocoon:before-remove', (e, el_to_remove) ->
$(this).data('remove-timeout', 1000)
el_to_remove.fadeOut(1000)
addresses.on 'cocoon:after-remove', (e, removed_el) ->
recount()
Limit?
You may wonder whether it is possible to limit the number of nested records somehow. The accepts_nested_attributes_for method does support the :limit
which specifies the maximum number of associated records that can be processed. It can be supplied with an integer, a procedure, or a symbol pointing to a method (both the procedure and the method must return an integer).
Cocoon, however, does not support limiting of the nested records at the time of writing this article. There was an discussion regarding this issue but the author does not consider it to be a core feature. Still, there is an open pull request adding this functionality available that may be merged some time in future.
Conclusion
In this article we’ve discussed the usage of the nested attributes in Rails. We’ve created a basic, nested form allowing users to add, edit, and destroy the associated records. Later we integrated the Cocoon gem and powered our form with jQuery, making it dynamic.
Cocoon has many more options available for customization, so be sure to browse its docs. Hopefully, this article was useful to you. As always, I thank you for staying with me and see you soon!
Frequently Asked Questions (FAQs) about Cocoon Gem in Rails
How do I install the Cocoon gem in Rails?
To install the Cocoon gem in Rails, you need to add it to your Gemfile. Open your Gemfile and add the following line: gem 'cocoon'
. After adding the gem, run bundle install
in your terminal to install it. Once installed, you can use it in your Rails application.
How do I use Cocoon gem for nested forms in Rails?
Cocoon gem simplifies the process of creating complex forms with nested attributes in Rails. After installing the gem, you can use the link_to_add_association
and link_to_remove_association
helpers provided by Cocoon in your form views. These helpers allow you to dynamically add and remove nested form fields.
What are the benefits of using Cocoon gem in Rails?
Cocoon gem provides a simple and efficient way to handle nested forms in Rails. It allows you to dynamically add and remove nested form fields without writing complex JavaScript code. It also provides a clean and DRY way to manage nested attributes in your models.
How do I handle validations with Cocoon gem?
Cocoon gem does not handle validations directly. You need to handle validations in your models. If a nested attribute fails validation, Rails will automatically highlight the invalid fields in the form.
Can I use Cocoon gem with other form builders like Simple Form or Formtastic?
Yes, Cocoon gem works well with other form builders like Simple Form and Formtastic. You can use the same link_to_add_association
and link_to_remove_association
helpers with these form builders.
How do I customize the add and remove links in Cocoon gem?
You can customize the add and remove links by passing a block to the link_to_add_association
and link_to_remove_association
helpers. Inside the block, you can add your custom HTML and CSS.
How do I handle complex nested forms with Cocoon gem?
Cocoon gem allows you to handle complex nested forms by nesting the link_to_add_association
and link_to_remove_association
helpers. You can nest these helpers as deep as you want to create complex nested forms.
Can I use Cocoon gem with Rails API?
Cocoon gem is primarily designed for Rails applications with views. If you are building a Rails API, you might not benefit much from using Cocoon gem as it mainly simplifies the process of creating nested forms in views.
How do I handle nested attributes in the controller with Cocoon gem?
You handle nested attributes in the controller the same way you would do without Cocoon gem. You need to whitelist the nested attributes in your controller using the params.require
and permit
methods.
How do I use Cocoon gem with Rails 7?
Cocoon gem works with Rails 7 the same way it works with other versions of Rails. You need to install the gem, use the link_to_add_association
and link_to_remove_association
helpers in your form views, and handle the nested attributes in your controller.
Ilya Bodrov is personal IT teacher, a senior engineer working at Campaigner LLC, author and teaching assistant at Sitepoint and lecturer at Moscow Aviations Institute. His primary programming languages are Ruby (with Rails) and JavaScript. He enjoys coding, teaching people and learning new things. Ilya also has some Cisco and Microsoft certificates and was working as a tutor in an educational center for a couple of years. In his free time he tweets, writes posts for his website, participates in OpenSource projects, goes in for sports and plays music.