/ Tags: RAILS / Categories: RAILS

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::Preview gives 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.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More