Introducing ActiveForm for Ruby on Rails

I've been using form objects for a long time and have found myself using the same patterns over and over so I developed this small library to do the groundwork for you.

What are form objects?


I've been over the use case for form objects in this post on moving away from fat models but wanted to go into more detail on how and why I use them here. I really believe in the utility of these objects; their ability to abstract and isolate logic in a simple and effective manner is unmatched, IMO.

The basic classification of a form object is a class that contains writable attributes, validations and logic to persist the attributes to ActiveRecord objects. These forms can also include other side-effects like background job triggers, emails, and push-notifications etc.

The simplest way to understand the concept is to think of them as a representation of a controller action where all of the business logic that happens in that controller action is abstracted into a form object. They can also be rendered, presented, or serialized like ActiveRecord objects.

Forms often contain more than one form of data
Similar to the physical form above, there's also another reason we might want to use forms; conceptually they do make more sense. Our endpoints often contain logic and data relating to more than one ActiveRecord object. With forms we can build a boundary around a concept, e.g. a tax return form will collect user data, earnings data, and withholdings data etc. With a TaxReturnForm we can encapsulate this logic and create a tangible concept within our applications.

Maybe we need to validate that the user has supplied their ID number when they return their tax forms but not when they are updating their account email address. With forms we can isolate these validations to the correct objects, making our apps cleaner and responsibilities more well defined. No more custom attributes to tell our ActiveRecord models when to run a validation and when to skip it - it's implicitly defined. 

Let's see the code!


Let's look at how we can write forms with this ActiveForm
  • The ActiveForm::Base module contains all of the logic to set up the form helpers.
  • The ActiveForm::SimpleFormHelpers module contains some additional logic to allow simple_form to auto detect the input types.
  • ActiveRecord::Base includes some methods around the typecasting that are not yet implemented for ActiveModel::Attributes, fortunately it's easy to set up. Alternatively you could always just specify the simple_form inputs 'as:' option e.g. <%= f.input :date_of_birth, as: :date %>

class UserRegistrationForm
  include ActiveForm::Base
  include ActiveForm::SimpleFormHelpers

  attr_accessor :user

  attribute :first_name
  attribute :last_name
  attribute :email
  attribute :password

  with_options presence: true do
    validates :first_name
    validates :last_name
    validates :email, format: { with: Devise.email_regexp, allow_blank: true }
    validates :password, length: { minimum: 8, maximum: 100 }
  end

  validate :email_unique?, if: -> { email.present? }

  def process
    return false unless valid?

    self.user = User.create(attributes)
    UserRegistrationJob.perform_later(user)
    UserMailer.registration_confirmation(user).deliver_later
    true
  end

  # uniqueness validations need to be done manually but we can still use the
  # default messages
  private def email_unique?
    return true unless User.exists?(email: email)

    errors.add(:email, :taken)
  end
end

Looking for a new challenge? Join Our Team


Do I still need to use strong params?
ActiveForm has the attribute allow-listing by default. Any attribute in the list will be allowed, and any defined as attr_{accessor,reader,writer} will not be populated when passed in as params. This means we no longer need to use strong_params in the controllers because the form has a clear definition of what it expects and protects us by design.

What if I need to pass in a resource? In the above example we're creating a user, but perhaps we are updating one. In this case we might want to pass in the current_user. We can do this by defining an attr_accessor and then passing the current_user in when initializing the form. For example:
UserEditForm.new(params: params, user: current_user)

Why process, not save? This is entirely up to you. However, it's good to stay consistent across your team so there's no confusion. I began using save but found there are some cases for forms where you aren't saving anything, such as when you are just triggering a job or push-notification. I found using process fits more cases so that's what I use. This is also typically the only method that is public on my forms.

How do I handle failures when persisting multiple objects? I recommend using ActiveRecord::Base.transaction blocks for any logic that updates more than one table. However, sometimes actions can't be rolled back and it is unfortunately unavoidable. For example, consider when we send emails during the call to process. If we send before saving a record and that record fails to save what do we do? We can't unsend that email. This situation is up to the developer to solve for each use case. I typically save everything I can first, and then call the side-effects afterwards. If the side-effects fail I can handle them elsewhere and retry when necessary.

Why do I have to write uniqueness validations myself? ActiveRecord does some magic internally to make uniqueness validations work. When you call unique on an AR instance, self is the object so ruby knows which attribute is being compared to the others in the database. This is important when validating uniqueness of a persisted record. If you are checking uniqueness of a new object and you only check if the attribute exists in the db, if it's persisted already you need to scope to any object other than the current one. While it's technically possible to implement this in ActiveForm, it's not straightforward. Writing the uniqueness validations yourself is easy so I felt it was better to leave this up to the developer

Programming is just organizing concepts in a way your future self will understand

How can we use this in the controllers?


class RegistrationsController
  def new
    @form = UserRegistrationForm.new
  end

  def create
    @form = UserRegistrationForm.new(params: params[:form])

    if @form.process
      redirect_to welcome_path, notice: 'Welcome, your registration was successful!'
    else
      render :new
    end
  end
end

The forms are simple to initialize. You can pass in params through the params keyword argument. Remember we don't need strong params anymore as the forms already handle rejecting invalid parameters for us so we can just pass in the raw params (remember you need to pass in the form params not the root params, if you name you instance variable form, then you can always just pass in params[:form]).

We can render the forms in the views and as we include ActiveForm::Validations we can also render the error messages. The controllers feel very familiar to using ActiveRecord models but we get the benefit of isolating request specific logic without cramming it into a ActiveRecord model that will be used in multiple controllers/actions.

While you could move logic for user authentication into forms, Devise is widely used and there are already well worn paths on how to manage authentication. I recommend following the traditional route and authenticate_user in the controllers then pass current_user into the form as a resource. Same goes for policies, I like pundit and continue to use that outside of my forms. I think that's ok.

What does rendering the form look like?


# app/views/registrations/new.html.erb
<%= simple_form_for @form, url: registrations_path, method: :post do |f| %>
  <%= f.input :first_name %>
  <%= f.input :last_name %>
  <%= f.input :email %>
  <%= f.input :password %>
  <%= f.submit 'Register' %>
<% end %>

No surprises here - it looks almost identical to ActiveRecord forms. I really like simple_form so I added a few helpers to correctly identify the input type. You can use this by including the ActiveForm::SimpleFormHelpers module in your form.

Notice how we specify the url and method. This is because ActiveRecord provides the persisted? method which form_for and simple_form_for use to check if it's a new or existing record and subsequently changes the method from post to put and also changes the text on the submit button automatically. I prefer to be explicit with this as it's clear and less magic. However, if you prefer you can easily define this method on your forms - just need to define def persisted? and return true or false.

So that's it?


Yeah pretty much. It's a small utility with lots of potential for creating maintainable apps.
There are some added bonuses here as well.

before_initilization and after_initialization callbacks

I use these callbacks a lot to find or initialize attributes or resources. e.g.
attr_accessor :user, :device

attribute :device_uid, :uuid

before_initialization -> { self.device = user.device if user.present? }
before_initialization -> { self.device_uid = SecureRandom.uuid }

Date fields
Date fields are handled differently to other fields in Rails. When you create a date select, Rails splits the field into 3 selects inputs: time inputs split into 5, e.g. year, month, day, hour, minute. These values are passed to the backend, not as params: { 'date' => '01/01/2001' } but as params: { 'date(1i)' => '01', 'date(2i)' => '01', 'date(3i)' => '2001' }. ActiveForm will automatically deserialize these dates and times based on the attribute type, e.g. if the attribute :date, :date exists then the params will try to merge these into a date object before assigning it to the form.

Boolean attributes
Boolean attributes include an aliased question mark method by default. We're all familiar with ActiveRecord boolean columns having two accessors, e.g. admin and admin?, ActiveForm will behave the same way e.g. attribute :admin, boolean will create an alias, admin?, for the admin attribute.

The 'attributes' method
 ActiveRecord::Base#attributes is cast to indifferent_access but ActiveModel::Model#attributes isn't. This means calling attributes[:email] fails, while attribute['email'] doesn't. ActiveForm overrides attributes and calls indifferent_access on the result so we can access with string or symbol keys.

ActiveModel::Attributes extendable nature
ActiveModel::Attributes are extendable so you can create your own types easily. I've created many over the time I've been using this lib. For example I have an EmailType to cast the email attributes to downcased  automatically, no more mixed case emails (of course you could also do this in postgres). Don't forget to tell Rails about your attributes in an initializer
class EmailType < ActiveModel::Type::String
  def cast(value)
    return nil unless value.is_a?(String) && value.present?
    super(value.downcase)
  end
end

Is this a gem?


Ruby gems are beautiful

For now I feel ActiveForm is still a bit early to transition to a gem  as there are still things to improve and work out. One day I'll invest more time into making it extendable and release it as a gem. For now I feel it's an unnecessary complexity.

If you would like to use it please download it from our GitHub, import it into your app and start building your own form objects. Feedback is 100% welcomed and pull-requests will also be accepted. I will continue to use form objects and push changes into the repo when I feel they are universally relevant and valuable.

My reasoning for not gemifying ActiveForm is that the custom not-rails-core logic is relatively small, and while ActiveForm currently fits 99% of my use cases, I do sometimes make modifications based on the product requirements. Manually importing the library into app/lib/active_form/ makes modification a lot simpler. I've utilized as many Rails modules as I can to make maintenance a lot easier as I just have to update Rails and I get the updates for free. By utilizing Rails core modules, it's a really small library - there are only 10 methods in the Base module!

You can checkout the source code in the repo above. It's two files with a lot of comments. If you need help understanding anything feel free to reach out. I am happy to explain parts you don't understand. The comments explain a lot so hopefully it won't be necessary. Also reach out if you have ideas on how to make it better.
Like 4 likes
Joe Woodward
I'm Joe Woodward, a Ruby on Rails fanatic working with OOZOU in Bangkok, Thailand. I love Web Development, Software Design, Hardware Hacking.
Share:

Join the conversation

This will be shown public
All comments are moderated

Get our stories delivered

From us to your inbox weekly.