7 min read

Form objects in Rails

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 new Room won't be saved unless the Picture 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 nested pictures_attributes in the room_params, you can see that accepts_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 get accepts_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 random nils 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 the Picture. Applications inevitably grow larger in complexity, thus it's entirely possible that our Room 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 refactored RoomsController 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 the Picture 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.