Form objects in Rails
Most applications have forms that take in a myriad of inputs from the user and saves the information that the user typed into the database.
Rails provides various helpers to build forms that interact with ActiveRecord
to easily create and persist user inputs into the database. Rails specifically provides the following helpers: form_for
, form_tag
, and form_with
.
Just as an FYI, while I am using Rails in this post, the form object pattern can be used with any web application framework. This is a general pattern that can be applied in many different application development environments.
These helpers are very easy to use when you're working with one data model. For example, let's say that we're building an application to manage rooms in hotels. In this application, we need to build a form to create rooms for the hotel so that we can book users to these rooms. Now, because we want to advertise these rooms in an eye-appealing way, we want to be able to attach multiple photos for each room. Here's an example of the Room
model that we'll be working with.
class Room < ApplicationRecord
belongs_to :hotel
has_many :pictures, as: :imageable, dependent: :destroy
validates :name, presence: true
end
And our Picture
model and the Hotel
model are as the following.
class Picture < ApplicationRecord
belongs_to :imageable, polymorphic: true
has_attached_file :image
validates_attachment :image, presence: true,
content_type: { content_type: 'image/jpeg' }
end
class Hotel < ApplicationRecord
validates :name, presence: true
has_many :rooms, dependent: :destroy
end
Our objective in this blog post will be to build a form to create rooms and one picture in the same form.
accepts_nested_attributes_for
- Rails's answer to nested associations in forms
As stated above, building a form to interact with one model is very easy. We just use one of Rails's built in form builders and map it to one instance of the model. However, when we want to associate multiple models within the same form, things get a little complicated. Rails provides a built in API called accepts_nested_attributes_for
to help us build nested forms that can handle multiple models that have associations with each other. To use accepts_nested_attributes_for
to build our Room form, we'll need to add that to our Room
model.
class Room < ApplicationRecord
belongs_to :hotel
has_many :pictures, as: :imageable, dependent: :destroy
validates :name, presence: true
accepts_nested_attributes_for :pictures
validates_associated :pictures
end
Our form for the Room
model will now have nesting to accommodate the pictures
association that we'll be building in. It'll utilize the fields_for
helper within the form.
<%= form_with model: Room.new do |f| %>
<%= f.label :name %>
<%= f.text_field :name %>
<%= f.fields_for :pictures_attributes do |pf| %>
<%= pf.label :image %>
<%= pf.file_field :image %>
<% end %>
<%= f.submit %>
<% end %>
And our controller to interact with this form will look like
class RoomController < ApplicationController
def new
@room = Room.new
end
def create
@room = Room.new(room_params)
if @room.save
# Success!
else
# Nooooo
end
end
private
def room_params
params.require(:room).permit(:name, pictures_attributes: [:image])
end
end
This is the standard Rails way of handling associations within forms. It works, but I'm not a fan of this method due to a few reasons. I'll go over the Pros vs Cons over this standard Rails way.
Pros:
It's built into Rails
- Yes, this is a pro. This feature is built into Rails which means it's ready for us to use. Rails team is maintaining the code for this feature and is improving and fixing bugs and we're relying on their work to build our nested forms. Having other people maintain features that help us build our applications is a pro in my opinion.
Rails handles validations for nested attributes for us and wraps up the operation in a transaction
- Because this is a feature built into Rails, validations that are defined at the model level will be handled by Rails for us. This reduces the work that we need to do as we're handing over this responsibility over to Rails.
- When using
accepts_nested_attributes_for
Rails will wrap the database operation in a database transaction so that associated records all get saved at once. In our specific example, this means that the newRoom
won't be saved unless thePicture
data is also correctly entered by the user.
Cons:
It increases nesting:
- If you take a look at the form with the
f.fields_for
along with the nestedpictures_attributes
in theroom_params
, you can see thataccepts_nested_attributes_for
helper in Rails increases nesting. I personally prefer avoiding nesting if possible because it makes the code easier to reason about for me. - In general, flat code is easier to reason about vs nested code. This is akin to having a lot of nested if statements making code more difficult to reason about.
accepts_nested_attributes
for is notoriously difficult to utilize (for me at least):
- I have no idea why, but every time I reach out for
accepts_nested_attributes
, I start getting stressed before I even get started. To getaccepts_nested_attributes
to work, I have to build the form, test it and wonder why it's not working, see which params are coming into the controller, rework the form, controller, and the model to see which one is at fault, and etc. - The error messages from Rails for the
accepts_nested_attributes
is not very helpful. You'll just see a bunch of error messages that don't make much sense and see a bunch of randomnil
s and empty params showing up, making it difficult to debug and getting your form working.
As your application gets more complicated, making changes to your nested forms become more difficult:
- In this specific example, we are working with only two associations:
Room
and thePicture
. Applications inevitably grow larger in complexity, thus it's entirely possible that ourRoom
model will acquire more complicated associates and we will have to increase the complexity of our nested form, nested params in our controller, validations in our models, and etc. This makes changes much more difficult to make
Form objects to the rescue
Form objects, while not the perfect end-all solution, can alleviate some of the cons that I listed above that come from using accepts_nested_attributes_for
helper from Rails. Within the context of Rails, form objects are essentially plain old Ruby objects that interact that handle the logic required to save your records. You don't even really need forms to utilize form objects. You can also use them as an intermediary between your incoming parameters and your controllers in strictly API applications as well. Let's refactor our above code using form objects.
First, remove the accepts_nested_attributes_for
and the validates_associated
helpers in the Room
model.
class Room < ApplicationRecord
belongs_to :hotel
has_many :pictures, as: :imageable, dependent: :destroy
validates :name, presence: true
end
And create a new Ruby class called RoomForm
that looks like this.
class RoomForm
include ActiveModel::Model
attr_accessor :hotel, :name, :image
validates :name, presence: true
validates :image, presence: true
def save
if valid?
ActiveRecord::Base.transaction do
room = Room.create!(hotel: hotel, name: name)
Picture.create!(imageable: room, image: image)
end
end
end
end
If you look at the RoomForm
class, you'll see that it's just a plain old Ruby object with the ActiveModel::Model
module from Rails thrown in there so that we can utilize its various validation helpers. We give the form object three attributes that are needed to create a new room and a picture via attr_accessors
and then validate the form object with Rails's built in validates
method. We then give the form object a save
method that will create the new room and the picture if the form object passes the validates
validations that we defined. It's also important to note that we wrap the create!
operations in a database transaction so that we don't accidentally create a new room without a picture.
Now we need to modify our RoomsController
to use our new RoomForm
instead of our Room
model.
class RoomsController < ApplicationController
def new
@room_form = RoomForm.new
end
def create
# Assume that we have this current_hotel helper method to get us
# the current hotel that's signed in
@room_form = RoomForm.new(room_form_params)
@room_form.hotel = current_hotel
if @room_form.save
# Yay
else
# Nooooooo
end
end
private
def room_form_params
params.require(:room_form).permit(:name, :image)
end
end
And finally our form itself
<%= form_with model: @room_form, url: rooms_path do |f| %>
<%= f.label :name %>
<%= f.text_field :name %>
<%= f.label :image %>
<%= f.file_field :image %>
<%= f.submit %>
<% end %>
If you actually load up the application and submit the form, everything should work. This refactoring with the form objects have several pros compared to the Rails's traditional accepts_nested_attributes_for
method. Unfortunately, it has cons as well (every pattern has a trade-off in software development).
Pros:
Eliminates nesting and promotes flat hierarchy:
- If you look at our new form that utilizes the
RoomForm
and the refactoredRoomsController
you can see that nesting is completely gone. This new flat hierarchy makes the code easier to reason about and to extend in the future.
Skinnier controller with business logic contained within the form object
- What usually happens with nested forms that interact with models that have complex associations is that the logic for saving these associations eventually gets long and complicated within the controllers. By moving away the logic for saving these associations out from the controller and into the form objects keeps the controllers tidy and neat.
- Because we're moving the business logic of handling saving of data into the form objects, we can reuse these form objects throughout the codebase, keeping our code DRY.
Cons:
Duplicate validations in both form object and the model:
- I mentioned how the form objects help our codebase become more DRY, but that isn't the complete truth. If you look at the validations in our form object, you'll notice that it's basically a repetition of the validations in the
Room
model and thePicture
model. Our example is simple so it's not THAT bad, but as our application grows in complexity, this could cause a toll in duplication.
As you can see, form objects eliminate the cons of using the default accepts_nested_attributes_for
helper while introducing new cons. Unfortunately, using this pattern will mean that you'll have to be willing to make the trade off with the slight increase of duplication in validation code.
At the end of the day...
I personally think form objects are worth integrating into your every day development whenever you start noticing nesting in your forms. I think the increase in validation duplication is worth the trade off in the flat hierarchy that you end up with in your forms and controllers.
This pattern is considered useful enough that there are gems out there that implement form objects form you. Personally, I find implementing form objects to be simple enough to roll out my own and I like the flexibility that I gain from self implementation. For those of you who are interested in using a gem instead, I see that https://github.com/trailblazer/reform has a lot of stars on GitHub and seems to have a lot of features built in that you can use out of the box. Also, the fact that it's associated with the Trailblazer framework should give it some credibility.