I've had this idea in the back of my head for a while that can potentially unlock a bunch of possibilities with Rails' Form Helpers. But to present it, I want to write about what you can do with them today, the limitations and what I want as a developer.
I'll be releasing 3 articles in quick succession over the next few days.
Part 1: An intro/refresher on
FormBuilder
Part 2: A deep dive into how
FormBuilder
and Tag Helper Classes build tags like<input>
or<select>
. Here we'll uncover what I believe is the opportunity to unlock new stuff.Part 3: A proposal for a solution with a link to a Rails Fork that's in progress.
You're using a FormBuilder even if you don't know what it is
Most of what I'll mention in this series and some of the examples come from the awesome Rails Guide: Action View Form Helpers
I have to be honest; I haven't written a plain HTML form in many years and I don't really know how to do it from memory any more. Rails form helpers provide a beautiful developer experience while keeping us true to the fundamentals of the web. What a combo!
When I learnt Rails for the first time I wrote forms that looked something like this one.
<%= form_with url: "/search", method: :get do |form| %>
<%= form.label :query, "Search for:" %>
<%= form.text_field :query %>
<%= form.submit "Search" %>
<% end %>
I typed the stuff and a form popped up. However, I never really understood what form
was, especially when working with Stack Overflow examples and older code that used just f
. What the hell is f
? (please, don't use f
).
Well, that form
is an instance of a class called FormBuilder
. The documentation has a great description of this class:
The +FormBuilder+ object can be thought of as serving as a proxy for the # methods in the +FormHelper+ module.
In other words, an instance of FormBuilder
, which is initialized when you call form_with
, wraps all the different form elements you can use to build a form in nice methods like label
, text_field
, button
that return plain HTML.
Usually, though, we don't have a plain URL to point our form to. More often than not, form_with
is called with a model. Like an instance of a @user
or @article
and this is where the benefits start multiplying.
<%= form_with model: @article do |form| %>
<%= form.text_field :title %>
<%= form.text_area :body, size: "60x10" %>
<%= form.submit %>
<% end %>
The docs continue with the following:
This class, however, allows you to # call methods with the model object you are building the form for.
To understand this, let's zoom in on the text_field
method in the example above. Assuming there is an Article
model with a title
attribute the output of form.text_field :title
will be:
<input type="text"
name="article[title]"
id="article_title"
value="My Title" />
Without you having to think about it, Rails added the following attributes to the resulting HTML tag:
name: with a convention of the model's class and then the attribute in squared brackets
id: with the model name first then the attribut ename
value
These are crucial for this element's interactions with things like an associated label
tag, JavaScript interactions that require a unique ID or generating a conventional and predictable params
hash in your controller to manipulate and persist data.
This is the most important part of forms in Ruby on Rails. The framework has already put in place everything you need to build a form specifically catered to a model and to receive the information back from that form in a predictable way.
(Keep this in mind because this is going to be crucial for this whole series.)
Styling form helpers
Directly in a view template
To style form helpers you can use the class
parameter and give it some value of a CSS class. In this case, we'll use the class "input"
<%= form.text_field
:title,
class: "input" %>
But what if all of your forms look the same and you always use the same class?
<%= form.text_field :title, class: "input" %>
<%= form.text_field :subtitle, class: "input" %>
<%= form.text_field :body, class: "input" %>
<%= form.text_field :footer, class: "input" %>
By using a Form Builder
You can also go one level deeper and customize a FormBuilder
with the styles you want. To do it though, you need to understand how a FormBuilder
method works. Let’s take text_field as an example (Source):
def text_field(object_name, method, options = {})
Tags::TextField.new(object_name, method, self, options).render
end
The method receives a few arguments and immediately delegates them to a new instance of a Tags::TextField
class and then calls render
on it. These Tag
classes (which are nested under the helpers folder in the Action View codebase) are kind of equivalent to what we now call a component: a class whose purpose is to render a bit of HTML.
Without going any deeper, we can just use our own FormBuilder
, modify the arguments to add our input
CSS class and call super
so Rails keeps doing its job.
class SomeCustomFormBuilder < ActionView::Helpers::FormBuilder
def text_field(object_name, method, options = {})
options.merge!(class: "input")
super(object_name, method, options)
end
end
This method is fine for simple interventions like adding a few classes and it cleans up your code quite a bit. It also makes it easy to change your code in a single place and affect every form.
<%= form.text_field :title %>
<%= form.text_field :subtitle %>
<%= form.text_field :body %>
<%= form.text_field :footer %>
More complex form elements
But form elements can become very complex. You may want to add a trailing and/or leading icon, a hint, an error message, or complex markup to create a checkbox that looks like a switch. Here are a few ways to solve that
Complex form builder methods
\There's going to be a bit of pseudo-code in this section to make my point so it may contain some errors.*
You can create markup directly in the form builder like so:
class SomeCustomFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, **args)
@template.capture do
@template.concat super(attribute, args.merge(class: 'a long string of classes if you use tailwind'))
if some_var = args.fetch(:some_argument, nil)
@template.concat @template.content_tag(:p, some_var., class: 'another long string of classes')
end
end
end
end
Even though this option works, it's super complex and hard to read. First, you need to be aware that you're writing stuff to a @template
instance variable to which you send the method capture
to encapsulate more HTML that you then have to concat
. This is not a nice way to write markup at all.
Delegating to a partial
To solve the above, you can just render a partial and create your markup there:
class SomeCustomFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, **args)
@template.render(
"some_custom_form_builder/text_field",
locals: { form: self, method: method, args: args)
)
end
end
<div>
<span>Some icon</span>
<%= self.text_field %>
<span><%= self.object.error_messages[method] %>
</div>
But there's a problem here. I'm calling self.text_field
in the template and that will reference the text_field
method in the form builder which will create an infinite loop and a stack overflow error.
To solve it you have two options. You either create an <input>
field manually and lose all of the work Rails has done to add an id
, name
and value
or rename the text_field
method in SomeCustomeFormBuilder
by prefixing it with something else like... some_custom_<method_name>
so that we can freely call text_field
. Let's take that second option:
class SomeCustomFormBuilder < ActionView::Helpers::FormBuilder
def some_custom_text_field(method, **args) # 👎
@template.render(
"some_custom_form_builder/some_custom_text_field",
locals: { form: self, method: method, args: args)
)
end
end
It's not bad. But it isn't great either. For a framework and a language that excels at ergonomics and naming, this is not very nice. This is how you would use this in a form:
<%= form_with model: @articel do |form| %>
<%= form.some_custom_text_field :article %>
<% end %>
What I want
I just want to be able to call form.text_field :article
and have it return whatever markup I want. For some people that might be just an <input type="text">
but for others that might be a full-on component with labels icons, hints, errors etc.
Finding the problem
I've done some digging and I think I know what's forcing developers to do this. Now that you know all about Form Builders, in the next article we'll dive even deeper into how something as simple as a TextField
is rendered and we'll uncover some coupling that I believe is holding form builders up.