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_submitandbefore_assign_form_attributes assigned_attribute_namesto 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 !