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.

class AppFormBuilder < ActionView::Helpers::FormBuilder
delegate :tag, :safe_join, to: :@template
def input(method, options = {})
@form_options = options
object_type = object_type_for_method(method)
input_type = case object_type
when :date then :string
when :integer then :string
else object_type
end
override_input_type = if options[:as]
options[:as]
elsif options[:collection]
:select
end
send("#{override_input_type || input_type}_input", method, options)
end
private
def form_group(method, options = {}, &block)
tag.div class: "form-group #{method}" do
safe_join [
block.call,
hint_text(options[:hint]),
error_text(method),
].compact
end
end
def hint_text(text)
return if text.nil?
tag.small text, class: "form-text text-muted"
end
def error_text(method)
return unless has_error?(method)
tag.div(@object.errors[method].join("<br />").html_safe, class: "invalid-feedback")
end
def object_type_for_method(method)
result = if @object.respond_to?(:type_for_attribute) && @object.has_attribute?(method)
@object.type_for_attribute(method.to_s).try(:type)
elsif @object.respond_to?(:column_for_attribute) && @object.has_attribute?(method)
@object.column_for_attribute(method).try(:type)
end
result || :string
end
def has_error?(method)
return false unless @object.respond_to?(:errors)
@object.errors.key?(method)
end
# Inputs and helpers
def string_input(method, options = {})
form_group(method, options) do
safe_join [
(label(method, options[:label]) unless options[:label] == false),
string_field(method, merge_input_options({class: "form-control #{"is-invalid" if has_error?(method)}"}, options[:input_html])),
]
end
end
def text_input(method, options = {})
form_group(method, options) do
safe_join [
(label(method, options[:label]) unless options[:label] == false),
text_area(method, merge_input_options({class: "form-control #{"is-invalid" if has_error?(method)}"}, options[:input_html])),
]
end
end
def boolean_input(method, options = {})
form_group(method, options) do
tag.div(class: "custom-control custom-checkbox") do
safe_join [
check_box(method, merge_input_options({class: "custom-control-input"}, options[:input_html])),
label(method, options[:label], class: "custom-control-label"),
]
end
end
end
def collection_input(method, options, &block)
form_group(method, options) do
safe_join [
label(method, options[:label]),
block.call,
]
end
end
def select_input(method, options = {})
value_method = options[:value_method] || :to_s
text_method = options[:text_method] || :to_s
input_options = options[:input_html] || {}
multiple = input_options[:multiple]
collection_input(method, options) do
collection_select(method, options[:collection], value_method, text_method, options, merge_input_options({class: "#{"custom-select" unless multiple} form-control #{"is-invalid" if has_error?(method)}"}, options[:input_html]))
end
end
def grouped_select_input(method, options = {})
# We probably need to go back later and adjust this for more customization
collection_input(method, options) do
grouped_collection_select(method, options[:collection], :last, :first, :to_s, :to_s, options, merge_input_options({class: "custom-select form-control #{"is-invalid" if has_error?(method)}"}, options[:input_html]))
end
end
def file_input(method, options = {})
form_group(method, options) do
safe_join [
(label(method, options[:label]) unless options[:label] == false),
custom_file_field(method, options),
]
end
end
def collection_of(input_type, method, options = {})
form_builder_method, custom_class, input_builder_method = case input_type
when :radio_buttons then [:collection_radio_buttons, "custom-radio", :radio_button]
when :check_boxes then [:collection_check_boxes, "custom-checkbox", :check_box]
else raise "Invalid input_type for collection_of, valid input_types are \":radio_buttons\", \":check_boxes\""
end
form_group(method, options) do
safe_join [
label(method, options[:label]),
tag.br,
(send(form_builder_method, method, options[:collection], options[:value_method], options[:text_method]) do |b|
tag.div(class: "custom-control #{custom_class}") {
safe_join [
b.send(input_builder_method, class: "custom-control-input"),
b.label(class: "custom-control-label"),
]
}
end),
]
end
end
def radio_buttons_input(method, options = {})
collection_of(:radio_buttons, method, options)
end
def check_boxes_input(method, options = {})
collection_of(:check_boxes, method, options)
end
def string_field(method, options = {})
case object_type_for_method(method)
when :date then
birthday = method.to_s =~ /birth/
safe_join [
date_field(method, merge_input_options(options, {data: {datepicker: true}})),
tag.div {
date_select(method, {
order: [:month, :day, :year],
start_year: birthday ? 1900 : Date.today.year - 5,
end_year: birthday ? Date.today.year : Date.today.year + 5,
}, {data: {date_select: true}})
},
]
when :integer then number_field(method, options)
when :string
case method.to_s
when /password/ then password_field(method, options)
# when /time_zone/ then :time_zone
# when /country/ then :country
when /email/ then email_field(method, options)
when /phone/ then telephone_field(method, options)
when /url/ then url_field(method, options)
else
text_field(method, options)
end
end
end
def custom_file_field(method, options = {})
tag.div(class: "input-group") {
safe_join [
tag.div(class: "input-group-prepend") {
tag.span("Upload", class: "input-group-text")
},
tag.div(class: "custom-file") {
safe_join [
file_field(method, options.merge(class: "custom-file-input", data: {controller: "file-input"})),
label(method, "Choose file...", class: "custom-file-label"),
]
},
]
}
end
def merge_input_options(options, user_options)
return options if user_options.nil?
# TODO handle class merging here
options.merge(user_options)
end
end

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.