Perfecting Your Rails Form
Part 1: Attribute Accessors For The Win

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:

  1. Attribute Accessors and #assign_attributes
  2. Nested Attributes: One-to-One and One-to-Many Associations
  3. Dynamic Forms with StimulusJS
  4. Customising Forms with Form builders

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

Rails Forms Are Scary

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.

Rails Forms: The Underappreciated MVC Glue

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.

Rails Guides: Factual but Unhelpful

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.

Embrace ActiveModel::Model

In this section, we’ll cover a few key points that I hope will make approaching forms in Rails feel more intuitive.

What's an Active Model?

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.

A Misleading Comparison

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.

The Unsung Method: #assign_attributes

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?

Make Peace with Attribute Accessors

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.

Inputs Aren't Just for Database Columns

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.

Part 1 Summary

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!