Building Your Own Rails Form Builders

Learn how to plug in to Rails' form builders to speed up application development and avoid duplication.

At Brand New Box, we write a lot of forms using Rails. Each of our projects has a unique look and feel to it which usually means that the HTML of our forms is structured a bit differently across each of our applications. As a developer switching between those projects you would have to learn exactly how the forms are structured and if we ever changed that it would involve us changing every form in the application to the new style. Here is how we leverage Rails' form builders to build our forms using the same API across all of our applications and also have a single place where the structure of the form is defined so that if we want to make a change to the structure everything is automatically updated. This allows our developers can quickly switch between projects and build forms consistently!

What are form builders?

The Rails framework provides many helpers for presenting data to users in your applications. One of those is the suite of form helpers which generate HTML elements for capturing input from your users. Specifially, whenever you use form_for or form_with Rails will give you a form builder which gives you quick access to these helpers. In the picture below, the f argument that is yielded to the block is an instance of Rail's default form builder (ActionView::FormBuilder).

code with form builder

This default form builder is a very low level API. You declare every label and field that you want in your form and you mix and match those calls with the HTML structure that you want your form to be rendered with.

This form builder has several of perks.

  • Labels go through Rails "titlization" process for user friendly names.
  • The for attributes are added to the label elements which is good for accessbility and usability of your form.
  • Rails pulls the current value of your model and prepopulates your inputs with those values.
form builder perks

But with all of it's perks, there are some disadvantages to the default form builder. Those annoyances mostly center around the amount of boilerplate that you have to write. If you have a specific structure or adding specific classes to your form inputs then you will have to add those classes every time that you call one of the form helpers.

form builder annoynaces

Custom Rails Form Builders

I don't think it's intended that anyone build their large scale application with only using the default Rails form builder. There are ways to get around some of the annoyances. Several people have built libraries dedicated to giving you a better API on top of the default. At Brand New Box we've used Simple Form quite a bit in the past for it's power and flexibility. It allowed us to abstract the structure of our forms and have a single place we could update that. That did come at a bit of a cost as our whole team had to learn the custom DSL that simple form provies you with and anytime that we onboard someone we also had to teach them.

= simple_form_for @user do |f|
  = f.input :username
  = f.input :password

However Rails gives us another ability, we can customize the default form builder that is used to build the forms. In fact, this is the technique that libraries like Simple Form and Formtastic use! They provide a custom form builder which is what you are using when you are calling methods like f.input.

We figured out that we could use that same technique and write our own custom form builder that was all of our own code.

= form_with model: @user, builder: AppFormBuilder do |f|
  .form-group
    = f.label :username
    = f.text_field :username

  .form-group
    = f.label :password
    = f.text_field :password

When using the builder argument Rails will yield an instance of the class that is passed in to the form builder. On that custom class we can add whatever methods that we want.

This is no doubt a very dense 211 lines of code. But the advantage here is that this code lives in our repository and it's normal Rails/Ruby code. Anybody on the team can edit it without having to learn a custom DSL.

Some features that we have built into this custom form builder include

  • f.input which generates a label and input for a given method of a model.
  • The proper input type is looked up based on the backing database column (it uses "text" inputs for string columns, "number" inputs for numeric columns, etc). We even try to detect if the field for the input might be a password field (by looking if it contains the word "password") and render a password input! That can also be customized by using the "as" argument to input.
    = f.input :description, as: :text
  • Automatic support of collection inputs with f.collection.
  • The structure of our inputs is able to be modified in a single place by editing the form_group method in the form builder.
  • Error messages are automatically added to our inputs.
  • Hints are supported with the "hint" attribute.

None of these features came for free, they all had to be built. But any developer on our team can look through the code and see how those things got there.

With all of those advantages we can now write the the previous form as follows.

= form_with model: @user, builder: AppFormBuilder do |f|
  = f.input :username
  = f.input :password

We even often make a special helper for generating a form using our custom builder.

def app_form_for(name, *args, &block)
  options = args.extract_options!
  args << options.merge(builder: AppFormBuilder)
  form_for(name, *args, &block)
end

def app_form_with(model: nil, scope: nil, url: nil, format: nil, **options, &block)
  options = options.reverse_merge(builder: AppFormBuilder)
  form_with(model: model, scope: scope, url: url, format: format, **options, &block)
end

Now that we have built out that custom form builder we copy it to each of our projects and make per projects customizations to it. The workflow has worked very well for our team. It has given us the right amount of power and flexibility while still being able to onboard new team members quickly.