Rails Form Helpers and ViewComponents — A Practical Guide
Your form markup is forty lines per field: a label, an input, a conditional error span, three utility classes, an aria attribute someone added once. Multiply by eleven fields and six forms and you have a design system implemented by copy-paste, drifting apart one PR at a time. Rails has two mechanisms for fixing this — custom form builders and ViewComponents — and they solve different halves of the problem.
The Problem, Concretely
Example:
<div class="field">
<%= f.label :email, class: "field__label" %>
<%= f.email_field :email,
class: "field__input #{'field__input--error' if @user.errors[:email].any?}",
aria: { invalid: @user.errors[:email].any?,
describedby: @user.errors[:email].any? ? "email-error" : nil } %>
<% if @user.errors[:email].any? %>
<p class="field__error" id="email-error"><%= @user.errors[:email].first %></p>
<% end %>
</div>
That’s one field. It’s correct — the aria wiring is right, which is more than most forms manage — and nobody is going to reproduce it accurately eleven times.
Custom Form Builders
A form builder subclass lets you override the field helpers so every form gets the wrapper for free.
Example:
# app/helpers/form_builders/application_form_builder.rb
module FormBuilders
class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
TEXT_HELPERS = %i[
text_field email_field password_field number_field
telephone_field url_field date_field text_area
].freeze
TEXT_HELPERS.each do |helper|
define_method(helper) do |attribute, options = {}|
field_wrapper(attribute, options) do
super(attribute, field_options(attribute, options))
end
end
end
def select(attribute, choices = nil, options = {}, html_options = {}, &block)
field_wrapper(attribute, html_options) do
super(attribute, choices, options, field_options(attribute, html_options), &block)
end
end
def check_box(attribute, options = {}, checked_value = "1", unchecked_value = "0")
@template.tag.div(class: "field field--checkbox") do
super + label(attribute, class: "field__label") + error_message(attribute)
end
end
def submit(value = nil, options = {})
super(value, { class: "btn btn--primary" }.merge(options))
end
private
def field_wrapper(attribute, options)
@template.tag.div(class: "field") do
label(attribute, options[:label], class: "field__label") +
yield +
hint(options[:hint]) +
error_message(attribute)
end
end
def field_options(attribute, options)
options.except(:label, :hint).deep_merge(
class: [options[:class], "field__input", ("field__input--error" if errors?(attribute))].compact,
aria: {
invalid: errors?(attribute).presence,
describedby: (error_id(attribute) if errors?(attribute))
}.compact
)
end
def hint(text)
return "".html_safe if text.blank?
@template.tag.p(text, class: "field__hint")
end
def error_message(attribute)
return "".html_safe unless errors?(attribute)
@template.tag.p(
object.errors[attribute].first,
class: "field__error",
id: error_id(attribute)
)
end
def errors?(attribute) = object.respond_to?(:errors) && object.errors[attribute].any?
def error_id(attribute) = "#{object_name}_#{attribute}_error"
end
end
Example:
# config/initializers/form_builder.rb — or per-form
ActionView::Base.default_form_builder = FormBuilders::ApplicationFormBuilder
The forty lines become:
Example:
<%= form_with model: @user do |f| %>
<%= f.email_field :email, hint: "We'll never share this." %>
<%= f.text_field :display_name %>
<%= f.select :timezone, ActiveSupport::TimeZone.all.map { [_1.name, _1.tzinfo.name] } %>
<%= f.check_box :terms_accepted %>
<%= f.submit "Create account" %>
<% end %>
Every field gets consistent markup, correct error wiring, and accessible aria attributes, with no opportunity to forget any of it.
The define_method loop over TEXT_HELPERS with super is the pattern worth internalising — it wraps every text-like helper identically without writing eight nearly-identical methods.
Where Form Builders Stop
They’re excellent at fields and awkward at anything else.
- Composite widgets — a currency input with a symbol prefix and a hidden cents field
- Anything with behaviour — a combobox, a file upload with a preview, a repeating fieldset
- Non-form UI that shares the same design language — badges, cards, empty states
- Testing in isolation — a form builder is tested through a rendered view, which is slow and indirect
That’s where components come in.
ViewComponents
Example:
# app/components/currency_field_component.rb
class CurrencyFieldComponent < ViewComponent::Base
def initialize(form:, attribute:, currency: "NPR", hint: nil)
@form = form
@attribute = attribute
@currency = currency
@hint = hint
end
private
attr_reader :form, :attribute, :currency, :hint
def cents_value = form.object.public_send(attribute)
def display_value = cents_value ? format("%.2f", cents_value / 100.0) : nil
def errors = form.object.errors[attribute]
def error? = errors.any?
def field_id = "#{form.object_name}_#{attribute}"
end
Example:
<%# app/components/currency_field_component.html.erb %>
<div class="field field--currency">
<%= form.label attribute, class: "field__label" %>
<div class="currency-input" data-controller="currency">
<span class="currency-input__symbol" aria-hidden="true"><%= currency %></span>
<input type="text"
inputmode="decimal"
id="<%= field_id %>_display"
value="<%= display_value %>"
class="field__input <%= 'field__input--error' if error? %>"
aria-invalid="<%= error? %>"
aria-describedby="<%= "#{field_id}_error" if error? %>"
data-currency-target="display"
data-action="input->currency#sync">
<%= form.hidden_field attribute, data: { currency_target: "cents" } %>
</div>
<% if hint %>
<p class="field__hint"><%= hint %></p>
<% end %>
<% if error? %>
<p class="field__error" id="<%= field_id %>_error"><%= errors.first %></p>
<% end %>
</div>
<%= render CurrencyFieldComponent.new(form: f, attribute: :price_cents, hint: "Excluding tax") %>
Combining Them
The two mechanisms compose — teach the form builder about your components.
Example:
module FormBuilders
class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
def currency_field(attribute, **options)
@template.render(CurrencyFieldComponent.new(form: self, attribute:, **options))
end
def combobox(attribute, choices, **options)
@template.render(ComboboxComponent.new(form: self, attribute:, choices:, **options))
end
end
end
<%= form_with model: @product do |f| %>
<%= f.text_field :name %>
<%= f.currency_field :price_cents, hint: "Excluding tax" %>
<%= f.combobox :category_id, Category.pluck(:name, :id) %>
<%= f.submit %>
<% end %>
Consistent call syntax whether the field is a plain input or a component with Stimulus behind it. Callers don’t need to know which is which, and you can promote a simple field to a component later without touching any form.
Testing
The strongest argument for components: they’re testable in isolation, in milliseconds.
Example:
RSpec.describe CurrencyFieldComponent, type: :component do
let(:product) { Product.new(price_cents: 4250) }
let(:form) { form_for_object(product) }
it "renders cents as a decimal amount" do
render_inline(described_class.new(form:, attribute: :price_cents))
expect(page).to have_field("product_price_cents_display", with: "42.50")
end
it "wires aria-describedby to the error when invalid" do
product.errors.add(:price_cents, "must be positive")
render_inline(described_class.new(form:, attribute: :price_cents))
expect(page).to have_css("input[aria-invalid='true'][aria-describedby='product_price_cents_error']")
expect(page).to have_css("#product_price_cents_error", text: "must be positive")
end
it "renders no error markup when valid" do
render_inline(described_class.new(form:, attribute: :price_cents))
expect(page).to have_no_css(".field__error")
end
end
Testing that aria wiring through a system spec would take seconds and require a browser. Here it’s a few milliseconds, which means you’ll actually write it.
Pro-Tip: Put a preview next to every component and treat it as the component’s documentation.
ViewComponent::Previewgives you a rendered page per state at/rails/view_components— default, with an error, disabled, with a long value that might overflow — and it costs about eight lines per component. Two things happen. Designers and other developers can see every state without hunting for a page that produces it, which is the whole reason component libraries get built and then unused. And you’re forced to enumerate the states while writing the component, which is when you notice the empty case and the overflow case you hadn’t handled. A component without a preview gets discovered by the next person only if they happen to grep for it; a component with one shows up in a browsable index.
Example:
# test/components/previews/currency_field_component_preview.rb
class CurrencyFieldComponentPreview < ViewComponent::Preview
def default
render_with_form(Product.new(price_cents: 4250))
end
def with_error
product = Product.new(price_cents: -100)
product.errors.add(:price_cents, "must be greater than zero")
render_with_form(product)
end
def empty
render_with_form(Product.new)
end
private
def render_with_form(product)
render_with_template(locals: { product: })
end
end
Choosing Between Them
| Use a form builder when | Use a ViewComponent when |
|---|---|
| Wrapping standard inputs consistently | The widget has internal structure or state |
| The rule applies to every field | You need Stimulus or JavaScript |
| You want zero call-site changes | You want isolated tests and previews |
| It’s presentation only | There’s logic deciding what renders |
Start with the form builder — it covers the majority of fields and requires no changes to existing forms. Promote individual fields to components as they grow behaviour.
Conclusion
A custom form builder eliminates the copy-pasted wrapper markup that makes forms drift, and the define_method plus super pattern applies it to every text-like helper in a few lines. ViewComponents handle the fields the builder can’t — composite widgets, anything with Stimulus, anything worth testing in isolation. Wire components back into the builder as custom helpers so call sites stay uniform. And write a preview for every component: it’s the documentation people will actually find, and enumerating the states is how you discover the ones you forgot to handle.
FAQs
Q1: Do I need the ViewComponent gem, or will partials do?
Partials work and have no isolated tests, no previews, no constructor to validate arguments, and locals that fail silently when misspelled. For a handful of shared bits, partials are fine; for a design system, the gem earns itself back quickly.
Q2: Can I use a custom form builder for only some forms?
Yes — form_with model: @user, builder: FormBuilders::CompactFormBuilder. Setting default_form_builder applies globally and can be overridden per form.
Q3: How do ViewComponents perform against partials?
Faster — components compile to a method call rather than going through partial lookup and rendering. The difference is small individually and adds up in a collection rendering hundreds of items.
Q4: Does this work with Turbo Streams?
Yes. turbo_stream.replace "price_field", renderable: CurrencyFieldComponent.new(...) renders a component directly into a stream, which is one of the neater combinations available.
Q5: Where should components live?
app/components/, with the template alongside the class as component_name.html.erb. Sidecar directories (app/components/currency_field_component/) are also supported and are worth it once a component has its own JavaScript and CSS.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀