In this series, we’ll take a deep dive into how to make our Rails forms more powerful and user-friendly. The series will include:
#assign_attributes
If you’re interested in exploring these techniques at your own pace, check out railsamples.com, a site I created to curate form examples for common web scenarios. You’ll find single-file Rails apps that demonstrate each technique with minimal setup
Have you ever heard?
Don't use
accepts_nested_attributes_for
this is too confusing
This is because the interaction between models and forms is one aspect of the so often mentioned 'Rails Magic'
There’s a common fear in the Rails community when it comes to forms—especially those involving nested attributes. And there’s some truth to it. The interactions with models can feel obscure, and updating or destroying records using nested attributes is anything but straightforward. Plus, what exactly does _destroy
do, anyway? Because of this, many developers turn to Ruby libraries like simple_form, nested_form or formstatic, hoping to "set it and forget it" when it comes to forms.
However, the lack of familiarity with the relationship between models and forms often leads developers to reinvent fragile or incomplete solutions, using patterns like service objects, rescue patterns, or result objects. It sometimes feels like we, as Rails developers, are willing to do anything to minimise our exposure to HTML and CSS, as if we have a deep-seated aversion to the web. Part of me even believes that we’d rather delegate form handling to React developers than deal with it ourselves.
What many developers overlook is that forms are excellent at surfacing errors encountered during submissions. By implementing alternative ways to interact with the backend, such as service objects, we often discard all the built-in tools that help users understand what’s wrong with their inputs. Instead, we end up creating new conventions for displaying errors, rather than simply including the ActiveModel::Validations
module in a class. Forms and models also integrate seamlessly with I18n
, a feature that’s often neglected when riding off rails until it's too late.
The fact that we start by writing forms with <%= form_with model: @my_record do |f| %>
should be a clear indicator that we need an object. This object can be a simple Active Record model, or any concept that encapsulates the form’s purpose, as long as it includes ActiveModel::Model
. This is where result objects or global functions like service objects often fall short. The patterns we use for business logic frequently don’t align with the structures that make form handling in Rails effective.
It’s not entirely our fault as developers. While the "Rails guides - Building complex form" and the API documentation are extremely well-written and provide everything needed to make the most of Rails forms, many developers still find themselves unfamiliar with them.
These guides do a great job of describing form capabilities but often fails to explain how to use them in common web interactions. Reading them can feel like gaining an encyclopedic understanding of world philosophies without learning how to live a good life.
In this section, we’ll cover a few key points that I hope will make approaching forms in Rails feel more intuitive.
Active Models are instances of classes that include ActiveModel::Model
or ActiveModel::API
. These libraries offer helper methods that simplify building and managing object states. As the Rails guides explain, they provide attribute methods, callbacks, dirty tracking, validations, serialization, and internationalization support.
Many developers assume that Active Models work similarly to Active Records, but this is misleading. It frames Active Models as lesser components, when in fact, they form the foundation on which Active Record is built. It’s more accurate to think of an Active Record as an Active Model with a persistence layer rather than viewing an Active Model as an incomplete Active Record without persistence. This distinction is why ActiveModel::API
is the first module included in ActiveRecord::Base
.
Active Models (and Active Records) have an interface similar to Ruby structs, thanks to the #assign_attributes
. This method is the essential link between objects and Rails forms. It is used to initialize objects, and update them.
When writing post.update(title: 'title', published: true)
, or Post.new(title: 'title', published: true)
you're actually using #assign_attributes
under the hood, which literally loops over each attribute accessor and assigns the values. This means that the following three code blocks are equivalent:
post.update(title: 'title', published: true)
same as
post.assign_attributes(title: 'title', published: true)
post.save
same as
post.title = 'title'
post.published = false
post.save
Question: Why is it acceptable to use accessors for ActiveRecord objects but discouraged elsewhere in the codebase?
One thing that makes many Ruby developers cringe is the use of public writers and accessors in a Ruby class. This aversion is amplified by a trend in Ruby toward functional paradigms and immutability whenever possible. But this just isn’t how Rails handles forms.
Working with forms means allowing users to provide input, often to create or modify database records. If you want to build effective forms in Rails, it’s essential to recognize that accessors are a crucial part of the solution. Design your classes with this in mind. Attribute accessors can align with high code standards—learn to define them properly.
Rails forms aren't limited to Active Record database column; they are bound to object accessors. This means you can expose various accessors to your users without needing a one-to-one mapping with your database schema.
For examples this is perfectly valid code:
class Repository
include ActiveModel::Model
attr_reader :owner, :name, :path
validates :path, format: {
with: /\A\w+\/\w+\z/,
message: 'must match "owner/repository" format using only letters or digits'
}
def path=(path)
return unless path
@path = path
@owner, @name = path.split("/")
end
end
<%= form_with model: @repository do |f| %>
<%= f.label :path %>
<%= f.text_field :path, placeholder: 'owner/repository' %>
<%= f.submit %>
<% end %>
In this example, we’re primarily interested in capturing :owner
and :name
. While we could expose these attributes directly as inputs, we can also provide a custom :path
attribute in the form to capture them. Note that we’re only using an Active Model here, but it could just as easily be an Active Record with :owner
and :name
as database columns and :path
as a custom accessor.
The database columns do not need to match the inputs exposed on a form. Any attribute accessor can be used—persisted or not.
Here is what happens under the hood.
# 1. Form is submitted and controller receives params as
params == { repository: { path: 'alexb52/retest' } }
# 2. We parse the params sent over
def repository_params
params.require(:repository).permit(:path)
end
# => { 'path' => 'alexb52/retest' }
# 3. We pass the params to the model
Repository.new(repository_params)
Repository.new('path' => 'alexb52/retest')
# 4. ActiveModel#assign_attributes kicks in
repository = Repository.new
repository.path = 'alexb52/retest'
# 5. repository#path= sets all the expected readers
repository.path
#=> alexb52/retest
repository.owner
#=> alexb52
repository.name
#=> retest
Check out this working example of the explanation above on railsamples.com. Feel free to explore other examples on the site to learn more about patterns for working with Rails forms.
This approach opens up many possibilities when capturing user input. Custom accessors also enable consistency in controllers, allowing strong parameters to be passed directly to an object. This technique works with any pattern you choose for business logic—whether it’s a service object, form object, serializer, Active Model, or Active Record—as long as the object includes ActiveModel::Model
and uses ActiveModel::Validations
to relay error messages back to the user.
Can you think of other examples where custom accessors might be useful?
In the next article, we’ll explore how custom accessors enable nested attributes, including one-to-one and one-to-many associations. Stay tuned!