Post

Form objects in Rails (and why I built HyperActiveForm)

Form objects in Rails (and why I built HyperActiveForm)

Rails comes with great tools for building forms. When dealing with a single model, everything works smoothly. But as soon as your form needs to touch multiple models, or to have different validations based on context, it can start to get complicated.

In this article we’ll look at the standard Rails approach to forms, some of the pain points we’ve all probably encountered, and how form objects can help.

The standard Rails way

When we use form_with(model: ...), Rails ties the form to the model object.

1
2
3
4
5
<%= form_with(model: @user) do |f| %>
  <%= f.text_field :first_name %>
  <%= f.text_field :last_name %>
  <%= f.submit %>
<% end %>

This is the happy path. Rails can automatically:

  • infer the URL from the model class
  • infer the HTTP method from @user.persisted?
  • pre-fill fields from model attributes
  • surface model validation errors nicely in the view

In the controller, we just deal with the model, and strong params:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class UsersController < ApplicationController
  def update
    @user = current_user

    if @user.update(user_params)
      redirect_to root_path, notice: "Profile updated"
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.require(:user).permit(:first_name, :last_name)
  end
end

Where it starts hurting

Real forms are often more complicated than a single model mapping. They can involve multiple models, and different validations based on the context.

Let’s imagine a single “profile settings” page that updates:

  • User (name, timezone)
  • Company (billing email)
  • NotificationPreferences (email/slack toggles)

At this point, we often end up with a lot of glue code in the controller, or with heavy model coupling through accepts_nested_attributes_for.

1
2
3
4
5
6
7
class User < ApplicationRecord
  has_one :company
  has_one :notification_preference

  accepts_nested_attributes_for :company
  accepts_nested_attributes_for :notification_preference
end

I don’t know about you but seeing accepts_nested_attributes_for always gives me the jitters.

Validations are another pain point: Model validations are global, but forms are contextual by nature. Sometimes you want a field to be required in one form, but optional in another. Or you have a field that’s not even on the model, but is still required for a specific flow.

Imagine on our sign up form we want a “Terms of Service” checkbox that’s required, we could imagine adding a tos_accepted boolean field to the User model just for that. But we end up with this column & validation that are only relevant for the sign up flow. We just leaked a flow-specific concern into the model.

1
2
3
class User < ApplicationRecord
  validates :tos_accepted, acceptance: true, on: :signup
end

Form objects with plain ActiveModel

We can avoid these issues by introducing form objects.

Form objects are basically plain Ruby objects that mimic model behavior (model_name, validations, errors, etc.) so Rails form helpers can still work with them.

A very small example could look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# app/forms/signup_form.rb
class SignupForm
  include ActiveModel::Model

  attr_accessor :email, :password, :company_name

  validates :email, presence: true
  validates :password, presence: true, length: { minimum: 12 }
  validates :company_name, presence: true

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      company = Company.create!(name: company_name)
      User.create!(email: email, password: password, company: company)
    end

    true
  rescue ActiveRecord::RecordInvalid
    false
  end
end

Then the controller stays pretty much the same as before, but now we deal with the form object instead of the model:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# app/controllers/signups_controller.rb
class SignupsController < ApplicationController
  def new
    @form = SignupForm.new
  end

  def create
    @form = SignupForm.new(signup_form_params)

    if @form.save
      redirect_to root_path, notice: "Welcome!"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def signup_form_params
    params.require(:signup_form).permit(:email, :password, :company_name)
  end
end

And the view:

1
2
3
4
5
6
<%= form_with(model: @form, url: signup_path) do |f| %>
  <%= f.email_field :email %>
  <%= f.password_field :password %>
  <%= f.text_field :company_name %>
  <%= f.submit "Create account" %>
<% end %>

We successfully extracted the form logic out of the controller and models, allowing us to do whatever we want in the form object such as talking to multiple models, or having form-specific validations without polluting the model.

Going further

This is already great, but it does involve a bit of boilerplate: defining attr_accessor for each field, setting url: in the view, maintaining the strong params list in the controller, pre-filling the form fields in the controller, etc.

To address these issues, I created HyperActiveForm, a small gem that provides some extra features on top of the plain ActiveModel form object approach. This is literally a 100 LOC single file gem.

It’s a simple form object implementation for Rails, built on top of ActiveModel::Model + ActiveModel::Attributes semantics,

What it gives you

1. Explicit attributes (and no strong params ceremony)

You declare allowed form fields explicitly with attribute.

Only declared attributes are assigned on submit, so you don’t need to maintain a permit(...) list in every controller action.

1
2
3
4
5
class ProfileForm < ApplicationForm
  attribute :first_name
  attribute :last_name
  attribute :birth_date, :date
end

I like this because the form becomes the single source of truth for what can be written, and when you open a form object class you immediately know what it deals with.

2. setup to pre-fill without bloating controllers

setup is called right after initialization and receives the same args. This can be used to pre-fill form attributes from the underlying model. Keeps the logic in one place and the controllers clean.

1
2
3
4
5
def setup(user)
  @user = user
  self.first_name = user.first_name
  self.last_name = user.last_name
end

3. proxy_for to truly mimic model behavior

proxy_for User, :@user delegates model metadata and persisted state so Rails can infer URL/method automatically, as far as Rails knows, it’s dealing with a User object.

This removes a surprising amount of view/controller glue.

1
2
3
4
5
6
7
8
9
class ProfileForm < ApplicationForm
  proxy_for User, :@user

  # Form is initialized with `ProfileForm.new(user: some_user)`
  def setup(user:)
    @user = user
    # etc
  end
end

4. add_errors_from for model-level validation forwarding

When underlying model validations fail, you can forward those errors to the form object in one line.

1
2
3
def perform
  @user.update(first_name: first_name) || add_errors_from(@user)
end

Some validations make sense at the model level, and we don’t want to duplicate them in the form object. add_errors_from makes it easy to keep the form object errors in sync with the model errors when that happens.

5. A few extra batteries included

  • submit / submit! workflow with clear semantics
  • callbacks like before_submit and before_assign_form_attributes
  • assigned_attribute_names to know which attributes were actually sent (useful for partial updates)
  • generators (rails generate form FooBar) to scaffold quickly

Nothing revolutionary. Just enough to make form objects easy and consistent along the way.

A minimal full example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# app/forms/profile_form.rb
class ProfileForm < ApplicationForm
  proxy_for User, :@user

  attribute :first_name
  attribute :last_name

  validates :first_name, presence: true

  def setup(user:)
    @user = user
    self.first_name = user.first_name
    self.last_name = user.last_name
  end

  def perform
    @user.update(first_name: first_name, last_name: last_name) || add_errors_from(@user)
  end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def edit
    @form = ProfileForm.new(user: current_user)
  end

  def update
    @form = ProfileForm.new(user: current_user)

    if @form.submit(params[:user])
      redirect_to root_path, notice: "Profile updated"
    else
      render :edit, status: :unprocessable_entity
    end
  end
end
1
2
3
4
5
<%= form_with(model: @form) do |f| %>
  <%= f.text_field :first_name %>
  <%= f.text_field :last_name %>
  <%= f.submit %>
<% end %>

Conclusion

Default Rails forms are great for simple CRUD that maps directly to a single model.

But when a form becomes a complex piece of logic instead, form objects are often a better fit: clearer responsibilities, cleaner controllers, and context-specific validations without turning models into giant conditional blocks.

If you want to try this approach with minimal boilerplate, have a look at hyperactiveform.

Thanks for reading !

This post is licensed under CC BY 4.0 by the author.