One of the ironies of working with Ruby on Rails is that making a feature do less often results in more work. For a great example of this, consider the ingredients that go into a standard Rails form: route, controller, ERB template, Active Record model. If you do things “The Rails Way”, we could have everything working in just a few minutes.

Now take that form and swap out the Active Record models in favor of plain ol' Ruby objects. Suddenly nothing works! (That’s what we get for going off the Rails, I guess.)

I recently had to work through this myself, but arrived at a surprisingly simple solution enabled by the sorta-public-but-still-kinda-private Attributes API.

In this post, we’ll walk through why rendering forms for non-Active Record model objects is so difficult and how mixing in the Attributes API can make things an awful lot easier on ourselves.

How might we make a filterable list?

“Why wouldn’t you want to persist a form?”, a hypothetical antagonist might ask. You might respond that one good example of an ephemeral, unpersisted form would be criteria fields used to filter a list of items. Something like this, to illustrate:

A form with price, color, and date filters

Let’s suppose the app will filter a list of items based on the provided criteria. These conditions don’t need to be persisted themselves—they’re inherently ephemeral, and a simple stateless form is all the feature calls for.

In this post, we’ll build the form step-by-step, and we’ll see why the Attributes API is a great choice for capturing values from unpersisted form fields.

Building a form with plain ol’ Ruby objects

Let’s start off by adding a date field.

The easiest thing to do would be to add each filter field as an instance variable, first in the controller action:

@available_on = params[:available_on] || Time.zone.today

And then in the view:

<%= f.date_field :available_on, value: @available_on %>

Storing each field in an instance variable will work, but it won’t scale very well if we have a lot of filters, if a different set of filters should appear based on the type of list, or if certain types of filters might appear multiple times.

Knowing this, we might decide to create a plain ol’ Ruby object (a “PORO”) to encapsulate the user’s input:

class Filter
  attr_reader :available_on

  def initialize(available_on:)
    @available_on = available_on || Time.zone.today
  end
end

And update the field:

<%= f.date_field :available_on, value: @filter.available_on %>

Because I tend to write a lot of repetitive-looking PORO value objects, I tend to reach for Struct in cases like this. This value could be reworked as:

Filter = Struct.new(:available_on, keyword_init: true) do
  def initialize(**kwargs)
    super
    self.available_on ||= Time.zone.today
  end
end

On first load, all three of the above approaches will render the form fine, and will continue rendering the selected value correctly after each form submission.

However, actually using this date as a Date will prove problematic. Adding this filter operation to our controller action would work on the initial render:

@items.select { |item| item.available_on <= @filter.available_on }

But after each form submission, it’ll raise an error:

ArgumentError: comparison of Date with String failed

This happens because we’ve gone off the Rails. It’s now our job to cast submitted form values from String into whatever we intend them to be. The reason we don’t normally need to worry about this when we’re building forms of Active Record models is because Rails looks at the underlying database table and says, “oh, available_on is a SQL DATE column, so I’ll parse this "2022-10-05" string I got from the form as a Ruby Date”.

Without a database table to inspect, and because form submissions collapse every value into a String, neither Ruby nor Rails will know what to do for us. Ruby’s dynamic typing is usually a convenience boost, but this is one case where we’ll need to be explicit about types.

We could try to update our Struct to convert the value in the initializer, in a custom writer method, or a custom reader method. Here’s one way we might hack up the initializer:

Filter = Struct.new(:available_on, keyword_init: true) do
  def initialize(**kwargs)
    super
    self.available_on = if kwargs[:available_on].present?
      Date.parse(kwargs[:available_on])
    else
      Time.zone.today
    end
  end
end

But just look at that. We don’t want a mess like that to propagate every time we add a non-string field to a form! Besides, this doesn’t even work, as this approach would be defeated if anyone called the attribute’s writer method (e.g. @filter.available_on = "2022-10-05").

If you’re anything like me, you’ll start thinking about the scope of the rest of the application and the next place your head will go is to ask, “should we extract a utility for defining value classes with typed attributes?” The answer to that question is, of course, no!

Instead (and you may have seen this coming), the Rails Attributes API is a better answer. It can already do all of this for us, and it would allow us to refactor our value object into:

class Filter
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :available_on, :date
end

And the action to:

@filter = Filter.new(available_on: params[:available_on] || Time.zone.today)

This will actually work! Even if instantiated with a string, you’ll always get a Date back:

filter = Filter.new(available_on: "2022-10-05")
filter.available_on.class # => Date
filter.attributes["available_on"].class # => Date
filter.available_on = "2022-11-01"
filter.available_on.inspect # => "Tue, 01 Nov 2022"

But now that our value object quacks a lot more like a Rails model, we can actually go all the way and pass our Filter object to the form builder.

Setting an Attributes object as the form builder’s model

We can make things even easier by passing any value that includes ActiveModel::Attributes as the model: keyword argument to form_with:

<%= form_with model: @filter, url: items_path, method: :get do |f| %>

Now the form can take responsibility for setting each field’s value, allowing us to simplify this:

<%= f.date_field :available_on, value: @filters.available_on %>

Into this:

<%= f.date_field :available_on %>

When we make this change, the :available_on param will no longer be on the top-level of the params object, but rather grouped under a filter key, so we need to update the controller action to match:

This change has the added benefit of passing to Filter.new only what fields are actually present, whereas passing nil or "" explicitly to keyword arguments would defeat most approaches to setting default values in an initializer.

@filter = Filter.new(params[:filter].permit(:available_on))

Because this change removes our || Time.zone.today short-circuit, we’ll need a different way to set a default value for the available_on. Good news: the Attributes API can help us here, too! All we need to do is add a default: to the attribute definition:

attribute :available_on, :date, default: -> { Time.zone.today }

Array attributes pair well with multi-select fields

Dates are relatively straightforward, but what about groups of checkboxes—like the ones pictured above, allowing users to filter items by color? Because the Attributes API is the same code that enables Active Record to support Postgres Array columns, we can create array attributes here, as well!

Let’s define a colors array attribute to our filter:

attribute :colors, array: true, default: -> { ["royal_blue"] }

And add them to our form:

The call to collection_check_boxes is a little tricky, because the method expects as its third and fourth arguments the names of methods for each element’s value and name, respectively. Since we’re passing a 2D array, we can tell Rails to call Array#first and Array#second on each item and it’ll “just work”

<%= f.collection_check_boxes :colors, [
  ["blue", "Blue"],
  ["royal_blue", "Royal Blue"],
  ["navy_blue", "Navy Blue"],
  ["raspberry_blue", "Blue Raspberry"]
], :first, :second %>

And pass them to Filter.new (note that permit requires us to pass colors as an array):

@filter = Filter.new(
  params[:filter].permit(:available_on, colors: [])
)

That’s all we need to see the checkboxes appear correctly on initial and post-submission renders of the form. If you look at the value returned, you’ll see an extra element with an empty string (due to a hidden field that Rails includes to ensure a value is submitted even if no items are checked):

> @filter.colors
=> ["", "royal_blue", "navy_blue", "raspberry_blue"]

We can work around this by either passing include_hidden: false to collection_check_boxes or defining a custom colors= writer to scrub the blank value (impressively, the latter will also correctly handle any colors passed to the initializer):

class Filter
  # …
  def colors=(colors)
    super(colors.reject(&:blank?))
  end
end

Writing validations for our attributes

Validations are another great feature of Active Record, but because they’re actually implemented on ActiveModel::Model we also have access to the validations API in our Filter objects, as well! (Earlier, we included ActiveModel::Model along with ActiveModel::Attributes to ensure the initializer was set up appropriately.)

For this example, let’s start by adding a min and max price as attributes to Filter:

attribute :min_price, :float, default: -> { 0.00 }
attribute :max_price, :float, default: -> { 100.00 }

And form fields:

<%= f.number_field :min_price %>
<%= f.number_field :max_price %>

Then update the controller’s invocation of Filter.new to include them:

@filter = Filter.new(params[:filter].permit(
  :available_on, :min_price, :max_price,
  colors: []
))

Out of the box, there’s nothing stopping a user from specifying a minimum price that’s higher than the maximum price. We could attempt to handle that edge case gracefully by adding a simple validation to ensure that max_price is equal to or less than min_price.

But since this class only exists in memory, maybe it makes sense to do something less drastic than show the user an error message. Here’s how we might simply clamp the min_price field’s value to ensure it does not exceed the max:

class Filter
  # …
  validate :min_isnt_more_than_max
  def min_isnt_more_than_max
    if min_price > max_price
      self.min_price = max_price
    end
  end
end

Then if we update our controller action to call @filter.validate before rendering the form, any min_price that exceeded the max_price would be set to the value of max_price. Nice!

Defining custom attribute types

Discerning readers will have grimaced when they saw a monetary attribute being set to a float. Currency values are often used in math, and combining fractional floats with division is a great way to end up with an inexact result. Instead, many people store the fractional portion (cents, in the case of dollars) as an integer and then convert the value to an accuracy-preserving BigDecimal or String representation when presenting the value to a user.

The Attributes API gives us a mechanism for defining and registering custom types that can be helpful in accomplishing this sort of type bifurcation. It’s primarily designed for serializing values to the database and deserializing values from the database, however, so it’s a little finnicky for use in stateless forms (as you’ll soon see).

Below, we’ll create a custom type that defines a cast method which will branch on the not-completely-bulletproof heuristic of assuming Numeric values are already in cents and any other values will need to be converted from dollars. This should work in our simple case, because every value from the form submission will be a String object.

So we could start with a custom type like this:

class Cents < ActiveRecord::Type::Integer
  def cast(value)
    return super if value.is_a?(Numeric)

    price_in_dollars = value.to_s.delete("$").to_d
    price_in_cents = (price_in_dollars * 100).to_i
    super(price_in_cents)
  end
end

This way, if a string is set to an attribute of this type (whether from the form via an initializer or by the setter method), any non-Numeric values will be assumed to be dollar representations and converted to cent integers. And if a Numeric value is set (for example, if we manually construct the value object from code), it will be left unchanged.

Next, we can globally register our custom attribute type to a symbol name:

ActiveModel::Type.register(:cents, Cents)

Then we can update our price attributes to use the new type:

attribute :min_price, :cents, default: -> { 50_00 }
attribute :max_price, :cents, default: -> { 100_00 }

Note that we also updated our default values from floats to integer cent values.

Now, and here’s the tricky part: how do we make the form display these cent values as dollars? The Attributes API itself doesn’t expose any presentational methods that might do this for us, so we need to define a method that will convert integral cents to dollars. Here’s a class method we could add to Cents that converts them to strings, replete with a leading $ character to be extra fancy:

class Cents < ActiveRecord::Type::Integer
  def self.dollarize(value)
    price_in_cents = value.to_d
    "$#{"%.2f" % (price_in_cents / 100)}"
  end
  # …
end

Again, because this is a custom method, we need to call it from the form, which we can do by referencing the attribute value from the form builder, like so:

Min:
<%= f.text_field :min_price, value: Cents.dollarize(f.object.min_price) %>
Max:
<%= f.text_field :max_price, value: Cents.dollarize(f.object.max_price) %>

This approach effectively split our value between a presentational mode (string dollars) and a more useful and portable logical value (integer cents). Caution is warranted, though. Introducing a custom attribute type is a significant enough deviation from the path of least surprise that I’d only consider doing so if it provided enough meaningful expressiveness to make up for the added code complexity.

Building dynamic forms with nested attributes

Suppose that we decide to expand the feature’s functionality so that the number and kinds of filter fields can vary dynamically. To accommodate this, we would need to split up our Filter object to support a form that grows and shrinks to allow arbitrarily many criteria types.

If you’ve ever tried to generate f.fields_for over a loop of nested hashes or Struct objects, it’s likely that all you remember is how painful it was. Rather than document the four or five edge cases that one needs to cover when manually generating nested fields, I’ll instead ask you take my word for it that iterating over an array of ActiveModel::Attributes objects is a lot easier.

We’ve built up quite a lot of the code in this post line-by-line, but the best way to illustrate this more fundamental change is to share it all at once.

First, if we decide to extract each category of criteria out of the Filter class and into its own standalone class, here’s what it might look like if we were trying not to get too fancy with metaprogramming:

module Criteria
  def self.type_for(name)
    all.find { |criteria_type|
      criteria_type.type_name.to_s == name.to_s
    }
  end

  def self.all
    [
      Price,
      AvailableOn,
      Colors
    ]
  end

  class Base
    include ActiveModel::Model
    include ActiveModel::Attributes

    attribute :id, :integer
  end

  class Price < Base
    attribute :min_price, :float, default: 0.00
    attribute :max_price, :float, default: 100.00

    def self.type_name
      :price
    end
  end

  class AvailableOn < Base
    attribute :date, :date, default: Time.zone.today

    def self.type_name
      :available_on
    end
  end

  class Colors < Base
    attribute :colors, array: true, default: ["royal_blue"]

    def self.type_name
      :colors
    end

    def self.supported_colors
      [
        ["blue", "Blue"],
        ["royal_blue", "Royal Blue"],
        ["navy_blue", "Navy Blue"],
        ["raspberry_blue", "Blue Raspberry"]
      ]
    end

    def colors=(colors)
      super(colors.select(&:present?))
    end
  end
end

It’s longer, sure, but ready to be mixed and matched!

Next, we would need to rewrite our controller action to consider both the initial render flow as well as each re-render when the form is updated:

class ItemsController < ApplicationController
  def index
    @items = Item.all
    @criteria = if params[:criteria_attributes].present? && params[:commit] != "Reset"
      params[:criteria_attributes].values.map { |criteria|
        criteria_class = Criteria.type_for(criteria[:type_name])
        criteria_class.new(criteria.except(:type_name))
      }
    else
      [
        Criteria::Price.new(id: 0),
        Criteria::AvailableOn.new(id: 1),
        Criteria::Colors.new(id: 2)
      ]
    end
  end
end

Note that the else expression above is the base case for rendering an empty form, and it’s here that we define which Criteria classes are instantiated and in what order. Also, it’s worth noting that any object passed to fields_for must return a distinct id in order for Rails to keep it separate it from its siblings in params. The value itself doesn’t matter, but keeping it sequential makes it easier to debug.

The last big change is to the view:

<div>
  <%= form_with url: items_path, method: :get do |f| %>
    <ol>
      <% @criteria.each.with_index do |criteria, i| %>
        <li>
          <%= f.fields_for "criteria_attributes[]", criteria do |ff| %>
            <%= ff.hidden_field :id, value: i %>
            <%= ff.hidden_field :type_name, value: criteria.class.type_name %>
            <%= render partial: criteria.class.type_name.to_s,
              locals: { ff: ff }
            %>
          <% end %>
        </li>
      <% end %>
    </ol>
    <div>
      <%= f.submit value: "Reset" %>
      <%= f.submit value: "Update" %>
    </div>
  <% end %>
</div>

Each of the form fields would then reside in a partial matching their class’s type_name. In app/views/items/_available_on.html.erb:

<%= ff.date_field :date %>

In app/views/items/_colors.html.erb:

<%= ff.collection_check_boxes :colors,
  Criteria::Colors.supported_colors, :first, :second %>

And in app/views/items/_price.html.erb:

Min: <%= ff.number_field :min_price %>
Max: <%= ff.number_field :max_price %>

In aggregate, this might feel like a lot of code, but hopefully it provides some clarity and may even serve as a starting point if you’re looking to build something similar.

The rail less traveled

When working with Ruby on Rails, it can often feel like any deviation from “The Rails Way” is akin to voiding the warranty on a new car: whatever goes wrong, you’re on your own. There were times when this rang true, but it’s genuinely impressive how modular Rails has become without sacrificing the batteries-included defaults that made it famous. And while Rails 7 is still very much omakase, it’s never been more accommodating of individual dietary restrictions.

This problem also serves as an interesting example of the tension between lexical complexity—that is, how much code we carry and how gnarly it is to maintain—as compared to operational complexity—the actual actions a program takes when it is run. Because Rails makes the reasonable assumption that most forms are backed by a database record, writing a database-free form that performs many fewer operations at run-time actually requires significantly more code. Both computing resources and programmer time cost money, but striking the right balance of trade-offs like these often requires careful thought and lots of context.

If your team could use more developers who appreciate the nuanced decisions needed to write great software, then you’re in luck—that’s exactly what Test Double sells! We’d love to talk to you if you might be able to use our help. 💚

Justin Searls

Person An icon of a human figure Status
Double Agent
Hash An icon of a hash sign Code Name
Agent 002
Location An icon of a map marker Location
Orlando, FL