In the previous article, I mentioned that Action View has some component-like classes that render form elements. Let's deep dive into a simple one: Tags::TextField
.
This class is used in the FormBuilder
like so (source):
def text_field(object_name, method, options = {})
Tags::TextField.new(object_name, method, self, options).render
end
Note that two things are happening here. First, an instance of Tags::TextField
is initialized with the necessary arguments
. Then render
is called which is what outputs HTML.
Let's take a peek into the class: (source) (I've compressed it and removed some irrelevant stuff for the article)
class ActionView::Helpers::Tags::TextField < Base
include Placeholderable
def render
options = @options.stringify_keys
options["size"] = options["maxlength"] unless options.key?("size")
options["type"] ||= field_type
options["value"] = options.fetch("value") { value_before_type_cast } unless field_type == "file"
add_default_name_and_id(options)
tag("input", options)
end
# ...
end
The render
method has some logic in it. It:
adds a few parameters to options like
size
andtype
calculates the
value
the input field should have usingvalue_before_type_cast
uses
add_default_name_and_id
to mutate the options hash to add conventional names and id values
Finally, after doing all of that, it renders the HTML tag using tag("input", options)
.
A lot of the heavy lifting is done by the parent Tags::Base
class and all of its included modules (source: again edited and slimmed down). Here's a sneak peek (don't stress about reading and understanding all of it. The details are not too relevant)
class ActionView::Helpers::Tags::Base
include Helpers::ActiveModelInstanceTag, Helpers::TagHelper, Helpers::FormTagHelper
include FormOptionsHelper
attr_reader :object
def initialize(object_name, method_name, template_object, options = {})
@object_name, @method_name = object_name.to_s.dup, method_name.to_s.dup
@template_object = template_object
@object_name.sub!(/\[\]$/, "") || @object_name.sub!(/\[\]\]$/, "]")
@object = retrieve_object(options.delete(:object))
@skip_default_ids = options.delete(:skip_default_ids)
@allow_method_names_outside_object = options.delete(:allow_method_names_outside_object)
@options = options
if Regexp.last_match
@generate_indexed_names = true
@auto_index = retrieve_autoindex(Regexp.last_match.pre_match)
else
@generate_indexed_names = false
@auto_index = nil
end
end
end
By heavy lifting, I mostly mean taking all of the necessary measures for the text field to conform to the HTML spec and Rails’ conventions for Action View, Active Record Active Model and Action Pack.
I've dug deep into this for several reasons:
I'm a long-time user and supporter of component libraries. ViewComponent and Phlex provide very interesting solutions for common problems when building views. They were born in the modern era, inspired by libraries like React or Vue which have created very interesting and useful patterns. There's nothing like cross-pollination for new ideas to emerge. And actually, Web Components fit in this bucket as well.
Using these libraries with Rails forms is very hard and up until a few months ago, there were a bunch of unsolved challenges when trying to do so. As an example, the ViewComponent authors had to build a Compatibility module to patch the
capture
method for this and other use cases.As we saw in the previous articles, even with Rails' core constructs (FormBuilder, partials etc) it's hard to build flexible form builder methods with nice interfaces.
Describing the problem
Here's my take. Rails form helper classes like Tags::TextField
have two different responsibilities.
Build the attributes for an Active Record/Active Model/Action Pack compliant form input and
render that input (i.e. Output the HTML)
The second one is pretty simple in most cases. Just use a tag
method and pass the tag name, and what are the arguments: tag("input", options)
. For some other cases, it's not that simple.
It's the first responsibility that's super difficult to do and it's written to work extremely closely with Rails conventions.
I believe it's this mix of responsibilities that makes it hard for other libraries and even Rails itself to provide a better developer experience when building new, custom form builder methods and helpers.
The solution
Split it. ✂️
I believe that if we split the attribute-building responsibility from the HTML rendering of tag helpers and make the attribute-building a public API, we can provide developers with the power of the current form builder methods without constraining the APIs exposed to the view.
A pseudo-code example
It's really pseudo-code so look past the errors and the poor naming of things 🙏🏽
Let's work with the Tags::TextField
class to imagine what that future could look like. As a reminder, this is how it currently looks inside of the FormBuilder
class
class FormBuilder
def text_field(object_name, method, options = {})
Tags::TextField.new(object_name, method, self, options).render
end
end
What if instead of a single text_field
method delegating to a single class, we had three different methods: one for rendering HTML, one for attribute building (both delegating to specialized classes) and one uses them both that’s exposed as the interface to build input fields:
class FormBuilder
# the method used when building a form in a template
def text_field(object_name, method, options = {})
attributes = text_field_attributes(object_name, method, options = {})
text_field_element(attributes)
end
def text_field_element(attributes)
# A new helper method and class responsible for text field renderring
Tags::TextFieldHTML.render(attributes)
end
def text_field_attributes(object_name, method, options = {})
# A new helper method and class responsible for attribute building
AttributeBuilder::TextField.build(object_name, method, options)
end
end
In this example, a developer using form.text_field :title
would see no difference. But this opens up a whole world of possibilities. Let's explore some.
Using component frameworks
With something like this, component frameworks like ViewComponent or Phlex wouldn't need to reimplement all of the smarts that Rails has put in place to work with Active Record, Active Model and Action Pack. They can simply focus on the specific logic and markup the developer needs to enhance form elements for their specific use case. Here's some pseudo-code.
class FormBuilder
def text_field(object_name, method, options = {})
# leverage the attribute-building part provided by rails
attributes = text_field_attributes(object_name, method, options = {})
# but replace the default text field renderer with a component
component = Components::SuperComplexTextField.new(
attributes: attributes,
object: self.object
)
@template.render component
end
def text_field_attributes(object_name, method, options = {})
AttributeBuilder::TextField.build(object_name, method, options)
end
end
In this example, we replace the HTML renderer from Rails' default one with a Component. How does the Component how to render an input field of type text? It can either:
inspire itself on the default one and then extend it with any specific details or
use the class provided by Rails and add any other markup or logic needed (if the Tag class is also became a public API)
Using partials
Remember the case we talked about in part 1 of this series where you had to come up with weird names for the builder's methods to prevent clashes? Well this approach can solve that
class FormBuilder < ActionView::Helpers::FormBuilder
def text_field(object_name, method, options = {})
# build attributes with new builder
attributes = text_field_attributes(object_name, method, options)
@template.render(
"form_builder/text_field",
locals: { form: self,
method: method,
options: options,
attributes: attributes,
}
)
end
end
<div>
<span>Some icon</span>
<!-- Render eleemtn with html helper -->
<%= self.text_field_element(attributes) %>
<span><%= self.object.error_messages[method] %>
</div>
Now there's no issue with names and the form builder method can still be named just text_field
.
If this has piqued your interest, I'll be publishing one last article that shows what it takes to make this happen. I've already forked Rails and started to understand how to tackle this work; from understanding the many different tags Rails already has, figuring out tests, to envisioning how many phases a project like this one might have.
Stay tuned!