---
title: "Build a Form Helper capable Form Object in Rails"
date: 2020-05-03T00:00:00+08:00
publishDate: 2020-05-03T16:29:36+08:00
lastmod: 2025-10-19T11:33:29+08:00
tags: ["Rails","Ruby","Ruby on Rails","Experience"]
toc: true
permalink: "https://blog.aotoki.me/en/posts/2020/05/03/Build-a-Form-Helper-capable-Form-Object-in-Rails/"
language: "en"
---


The Form Object is a common pattern in Rails when our form becomes complex. But the tutorial in network's example usually incapable of Rails' form helper.

And I start thinking about is it possible to support form helpers without too many changes?

<!--more-->

## Common Form Object Implementation

To improve the form object, you have to know about the original version you are using.

```ruby
class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Validations

  attr_accessor :email, :password, :password_confirmation

  def initialize(user, params = {})
    @user = user
    super params
  end

  # ...

  def attributes
    {
      email: @email,
      password: @password
    }
  end

  def save
    return unless valid?

    @user.assign_attributes(attributes)
    @user.save
  end
end
```

This is a common form object you can find in the network, it gives you model-like behavior which can use it in the controller without too many changes.

But in the view, the form helper has to set method and URL at any time.

```ruby
<%= form_for @form, method: :post, url: users_path do |f| %>
<% # ... %>
<% end %>
```

## The Form Helper

To improve the form object, I starting review the source code of form helper.

In the [action_view/helpers/form_helper.rb#L440](https://github.com/rails/rails/blob/bdc581616b760d1e2be3795c6f0f3ab4b1e125a5/actionview/lib/action_view/helpers/form_helper.rb#L440), the ActiveView will try to `apply_form_for_options!` if you give a object for it.

```ruby
apply_form_for_options!(record, object, options)
```

In the [`apply_form_for_options!`](https://github.com/rails/rails/blob/bdc581616b760d1e2be3795c6f0f3ab4b1e125a5/actionview/lib/action_view/helpers/form_helper.rb#L457-L474) method, you can find it set the `method` and `url`.

```ruby
action, method = object.respond_to?(:persisted?) && object.persisted? ? [:edit, :patch] : [:new, :post]
# ...
options[:url] ||= if options.key?(:format)
  polymorphic_path(record, format: options.delete(:format))
else
  polymorphic_path(record, {})
end
```

That means if the form object can provide the same interface to the form helper, it will correctly configure the form's method and URL without extra work.

## The persisted?

When the form helper decides to use `POST` to create a new object or use `PUT` to update an existing object. It depends on the model's `persisted?` method.

That means you can add `persisted?` method to the form object to make form helper can detect it.

```ruby
class BaseForm
  # ...

  def initialize(record, params = {})
    @record = record
    super params
  end

  def persisted?
    @record && @record.persisted?
  end
end
```

But there has another better way to implement it, you can use [`delegate`](https://api.rubyonrails.org/classes/Module.html#method-i-delegate) which provided by ActiveSupport.

```ruby
class BaseForm
  # ...

  delegate :persisted?, to: :@record, allow_nil: true

  def initialize(record, params = {})
    @record = record
    super params
  end
end
```

## The to_param and model_name

The URL is generated by [`polymorphic_path`](https://api.rubyonrails.org/classes/ActionDispatch/Routing/PolymorphicRoutes.html#method-i-polymorphic_path) and it uses `model_name` and `to_param` to generate the path.

You can try it in the Rails console:

```bash
> app.polymorphic_path(User.new)
=> "/users"
> app.polymorphic_path(User.last)
=> "/users/1234"
```

And when you add delegate `model_name` and `to_param` to the form object, you can get the same result.

```ruby
delegate :persisted?, :model_name, :to_param, to: :@record, allow_nil: true
```

And check it again:

```bash
> app.polymorphic_path(RegistrationForm.new(User.new))
=> "/users"
> app.polymorphic_path(RegistrationForm.new(User.last))
=> "/users/1234"
```

For now, you have the same interface as the model.

## Load Attributes

Since you can let form helper work correctly but you still cannot load the data when you edit an existing model.

To resolve it, you can adjust the initialize method to retrieve the necessary fields.

```ruby
class RegistrationForm < BaseForm
  def initialize(record, params = {})
    attributes = record.slice(:email, :password).merge(params)
    super record, params
  end
end
```

Another way is to use Attribute API to support it, but you have to exactly define each attribute in the form object.

```ruby
class BaseForm
  # ...
  include ActiveModel::Attributes

  def initialize(record, params = {})
    @record = record
    attributes = record.attributes.slice(*.self.class.attribute_names)
    super attributes.merge(params)
  end
end

# app/forms/registration_form.rb
class RegistrationForm < BaseForm
  attribute :email, :string
end
```

> But you have to take care the `params` hash, the model return attributes is `{"name" => "Joy"}` but you use `{name: "Joy"}` you will get the hash mixed string and symbol keys `{"name" => "Joy", name: "Joy"}` and didn't set the attribute to the form object.

## Future Improve

In the current version, you have to pass the model instance to the form object. Maybe you can add some DSL to auto-create it.

```ruby
# Option 1
class RegistrationForm < BaseForm
  model_class 'User'

  attribute :name
end

# Option 2
class RegistrationForm < BaseForm[User]
  attribute :name
end
```

But you also need to consider this way may not a good idea in some complex system.

For example, you had load the `User` from a controller or other object. But you cannot pass it to the form object. That means the form object usually loads the object when you access it.
If you have nested form it will cause the N+1 query in this case.

This is another topic when you use the form object or service object to refactor the code. You may reduce the duplicate code but cause the system to slow down or new hidden bug you didn't know about it.

## Conclusion

I didn't have many experiences to use the form object. But I think it is a common case when we build an application.
This version form object still has a lot of limitation and I didn't consider all possible use cases.

I will try to improve it in my future works and keep it as a simple object if possible. I believe we didn't always need to build a complex behavior and add a gem to resolve some simple things.

