Smoother, responsive Rails forms

Recently I shared some of my struggles writing forms in standalone JavaScript UI frameworks. Forms are:

  • an inevitable reality in web development
  • cruft that we need to capture data
  • things that allow us to actually provide value to users

Re-inventing the wheel on basic concerns like validation, storing and composing form data, and persisting it means we’re losing time and energy we’d rather dedicate to more valuable features. It’s sad when we give up the elegant tools Rails gives us to quickly build out forms.

Equally, I’ve found using modern styling solutions can feel cumbersome with Rails forms. Tailwind is great, especially in a component-based UI. There is a clear translation from components to partials / views, but I have never found the right way to think about how we build forms.

In the past, I’ve been guilty of copy-pasting from other forms in the project and tweaking to satisfy a new requirement. That’s the path of least resistance when you don’t have a decent strategy in place or are unfamiliar with where the right place to make an abstraction. It also leads to bad design choices by breeding inconsistency across teams. Every form drifts ever so slightly away from the original, like a bad game of telephone. Finding the right way to compose those forms is tricky!

I don’t think I’m alone there. All sorts of gems try to help smooth out that experience and make forms feel more ‘component-y’. Simple form is a great example! If you’ve never tried those gems, they might appeal to you!

Lately, I’ve been trying to engage my curiosity about the tools I use before jumping to use a dependency. Dependencies aren’t bad by any means! We just want to be very conscious of what tradeoff we’re making by including them. Often it’s worth the tradeoff, but by running straight to them, I limit my opportunity to go deeper in my understanding of the stack I use most often.

Queue a great idea from Justin Searls. He’s shown me how to lean on Rails defaults for building forms in a way that I think hits a sweet spot between views and Tailwind styling. With a small bit of exploration of the Rails internals, we might be able to free our forms from leaning on another dependency.

The challenge of Tailwindy Rails forms

Note: I’ll be writing .erb style syntax without their requisite <%= %> && <% %> tags, so they format more kindly in markdown. Please forgive me :`)

If you’ve also tried styling your Rails app with Tailwind, you might have made a form that looks like this:

# In our bakery show view, somewhere
form_with model: @bakery do |f|
  f.label :bakery_name, "Bakery Name", class: "block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
  f.text_field :name, class: "mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
  f.label :bakery_email, "Bakery Email", class: "block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
  f.text_field :email, class: "mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
  f.label :bakery_country, "Bakery Country", class: "block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
  f.text_field :country, class: "mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
  f.label :bakery_open, "Open?", class: "block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
  f.check_box :open, disabled: true
end

The principles of Tailwind and ActionView can feel opposed to each other.

The form_with helper gives us a reusable structure for creating forms. We directly interact with model attributes, with an easy DSL to build forms for an underlying object. Rails is opinionated about the behaviour, so we know what to do, with enough flexibility to customize the HTML output.

We get so many things for free. Validations against the model are tied to the matching field on the form. We can easily pass error messages to the view. The form knows where to submit itself and how to package up its form data. And all the other things.

Tailwind gives us a concise CSS DSL, blended with an inline first approach to styling that fits the componentized world of web development. Tailwind is heavily opinionated about applying style inline with your HTML, with a lot of benefits. No more abandoned CSS classes. Avoid abstractions that evolve, grow, bloat, and deteriorate. Mobile-first styling, easy theming, dark mode, and so on.

When we use these two tools together, we can wind up with a bit of a mess. The example code above is a case in point. Even a simple form with a few attributes has us copy-pasting the same styling to all of them. It feels like we’re doing something wrong. The styling is so wet that we lose sight of the simple form syntax that makes us love Rails, but we need to keep our styling inline with our HTML.

How do we reconcile this?

We can start by leaning on app-wide theme styling, letting Tailwind take care of those concerns at our config layer. Utilizing a Tailwind theme for things like our brand colors is the way.

If we slip and consider using @apply to add custom classes to our form elements, we break convention.


# no, bad. Don't be tricked!

form_with model: @bakery, class: 'bakery_form' do |f|
  f.label :bakery_name, "Bakery Name", class: "form-label"
  f.text_field :name, class: "form-text-input"
  f.label :bakery_email, "Bakery Email", class: "form-label"
  f.text_field :email, class: "form-text-input"
  f.label :bakery_country, "Bakery Country", class: "form-label"
  f.text_field :country, class: "form-text-input"
  f.label :bakery_open, "Open?", class: "form-label"
  f.check_box :open, disabled: true, class: "form-check-box"
end

We see this DRY style and feel better. It’s a more comfortable read if we’re used to traditional CSS approaches.

If we fall for this trap, reality will smack us as it always does.

What happens when we need to extend a single input style, one time, on one form? That one-off gets forgotten and regresses somewhere down the line.

What happens if bakery_form styles get reused? Once it makes a single leap to another form, it’ll make 3 more before you blink. Then we have a CSS class to maintain across multiple forms and contexts.

Changing global CSS is terrifying on large projects.

Inevitably, these abstractions push us away from what is awesome about Tailwind. There are a whole lot of reasons Tailwind tells us to avoid the @apply abstraction. Fear not!

Tailwind also gives guidance on managing duplication of styling, wisdom akin to “tolerate some amount of repetition over premature abstraction”. They coach us to dry out our inline styling in two ways; loop intelligently, and lean on partials. When possible, we should map over our collection of things to be rendered. If that doesn’t fit, lean into partials to share styling across contexts.

Forms fall into a challenging middle ground where neither of these choices solves our problem without issues.

Looping through model attributes to generate an input collection creates interdependency between each input. It’s also hard to reuse form partials. My Bakery model is very different from my Cake model, but I probably want similarity in the way the forms look.

There’s a better way to lean into Rails forms with Tailwind in an expected and concise way; Form builders.

The Rails Gods give us a way to make Custom Form Builders, and @searls pointed me towards a creative tie-in to utilizing them effectively while styling forms across an application.

The gist

TL;DR:

We can make a small seam in the FormBuilder Rails provides to us to apply styles, then jump right back to the default functionality.

I’ve got a sample app if you’d prefer I show you the code.

There is a principle I think pairs nicely with the form builder approach:

Wrap with structure, fill with style.

A slightly longer introduction

The sample repo’s “finished product” has some clever optimizations. If you’re comfortable with inheritance, metaprogramming, and a small bit of recursion, I think you’ll be set to hop in and grok the principle.

If those things are new to you, or you’d like a refresher, no worries! I’ve got you! I’ll detail the most simple implementation of this builder and incrementally work towards those clever optimizations step by step in the sections below.

Rails lets us define a custom Form Builder to change what happens when we call form_with in a view.

We’ll make a custom builder whose only job is to insert our styling.

Then the builder will point us straight back to Rails magic land, where we get validation, submission, data wrangling, and all the other things for free from the form_with helper.

First, I want you to remember the primary guiding principle that’s going to help us balance Tailwind and Rails best practices.

Wrap with structure, fill with style. We want to do layout styling in our view, wrapping our form_with elements. The form builder should fill in our appearance styling.

The setup

There are four things we need to get started:

  • Tailwind configuration, so it sees styles on the builder
  • How to call your form builder
  • Implementing the FormBuilder
  • Leveraging the builder alongside our form

The sample app and the examples below are based on an Adventurer model with a simple edit view.

├── app
│ ├── helpers
│ │ └── application_helper.rb
│ ├── lib
│ │ └── form_builders
│ │     └── tailwind_form_builder.rb
│ └── views
│     └── adventurers
│       └── show.html.erb
└── tailwind.config.js

tailwind.config.js

First, we want to configure Tailwind to check for styling in the form builder.

Find your tailwind.config.js, and include:

module.exports = {
  content: [
    './app/views/**/*.html.erb', // you probably have all these ones!
    './app/helpers/**/*.rb',
    './app/assets/stylesheets/**/*.css',
    './app/javascript/**/*.js',
    './app/lib/form_builders/**/*.rb',  // Add this one! Or, whatever directory you'd like to store your builder
  ]
}

Step one is done!

How to call the form builder

There are two primary ways Rails expects a custom builder to be invoked. The right place depends on what abstraction is appropriate for your project.

Every form_with helper takes an optional parameter builder, which is where we can pass a builder class.

In the example app, our show view invokes it this way:

<%= form_with model: @adventurer, builder: MyFormBuilder do |f| %>

Another place you might consider invoking your builder is as a project-wide default:

# app/helpers/application_helper.rb

module ApplicationHelper
  ActionView::Base.default_form_builder = MyFormBuilder
end

If you use the default, form_with doesn’t need to be given a builder option.

Which way should you go? As always, it depends!

If you’re building greenfield or have very few form views, setting a default makes a lot of sense! You can get a uniform appearance on every form you build and set up an effective pattern to customize each one as you go.

If you’ve got lots of pre-existing Rails views / forms you’re wrangling, it may be preferable to go view-by-view, manually passing your builder option. Drying up your forms this way can help untangle structure from appearance styling. It may also reveal if there are multiple “types” of forms in your domain you’d like to visualize differently.

Working towards a default implementation might be a decent goal, or you may need to keep working with a few different form styling options.

Regardless, the implementation of our first custom builder will be the same!

Implementing our builder

In Working Effectively With Legacy Code, Michael Feathers describes the concept of a code seam.

A seam is a place where two parts of a system interact, and we can introduce some new behaviour. We’re going to create our own seam in the FormBuilder api.

First, we define our builder and inherit from the default FormBuilder provided by ActionView.

# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
  end
end

I’ve opted to namespace this example, but you don’t have to.

Our ultimate goal is to give the form_with block access to each of the input field methods.

For example:

form_with model: @adventurer do |f|
  f.label :adventurer_name, "Adventurer Name", class: "block text-gray-500
 font-bold md:text-right mb-1 md:mb-0 pr-4"
  f.text_field :name, class: "mt-1 block w-full rounded-md shadow-sm
 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
  f.label :adventurer_city, "Adventurer City", class: "block text-gray-500
 font-bold md:text-right mb-1 md:mb-0 pr-4"
  f.text_field :city, class: "mt-1 block w-full rounded-md shadow-sm
 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
end

The easiest first step to drying this up is to define a text_field method on our custom builder that does all the styling.

# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
    def text_field(method, options={})
	  default_style = "mt-1 block w-full rounded-md shadow-sm focus:ring
        focus:ring-indigo-200 focus:ring-opacity-50"


	  super(method, options.merge({class: default_style}))
    end
  end
end

What’s happening here?

We’re moving our Tailwind class string into the text_field method and out of our view.

Then we leverage our inheritance powers to call the text_field method that Rails already provides for us on the default ActionView::Helpers::FormBuilder, by invoking super.

Now we can clean up our view!

form_with model: @adventurer do |f|
  f.label :adventurer_name, "Adventurer Name", class: "block text-gray-500
 font-bold md:text-right mb-1 md:mb-0 pr-4"
  f.text_field :name
  f.label :adventurer_city, "Adventurer City", class: "block text-gray-500
 font-bold md:text-right mb-1 md:mb-0 pr-4"
  f.text_field :city
end

All of our text fields get the same styling applied.

We can wash-rinse-repeat with the label too!

# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
	def text_field(method, options = {})
      default_style = "mt-1 block w-full rounded-md shadow-sm
        focus:ring focus:ring-indigo-200 focus:ring-opacity-50"

        super(method, options.merge({class: default_style}))
      end

    def label(method, text = nil, options = {})
      default_style = "block text-gray-500 font-bold md:text-right
        mb-1 md:mb-0 pr-4"

      super(method, text, options.merge({class: default_style}))
    end
  end
end

Note that Labels have a slightly different set of parameters than text_field. Now our view is tidy:

form_with model: @adventurer do |f|
  f.label :adventurer_name, "Adventurer Name"
  f.text_field :name
  f.label :adventurer_city, "Adventurer City"
  f.text_field :city
end

This could seem like a reasonable place to stop. Unfortunately, if we do stop here, we’re making more problems.

Our form is now dependent on the TailwindFormBuilder to create all the styling. It’s not unreasonable to imagine we could end up with a builder per form. At this stage, we are adding indirection and complexity for less benefit than just copy-pasting our styling. To make matters worse, we’re making every text_field co-dependent.

Changes to one affect them all.

So, we must keep going!

Our current example has two main dependencies we need to overcome:

  • All of the styling is in the builder method. We can’t adjust or modify each text_field
  • We have to write a method for every input type we want to be styled

If we could modify each input field in isolation, our builder code would become more reusable.

The second dependency might seem small, but there are 24 input types. Fortunately, 17 of them act exactly like a text_field. We can manage a lot of input types in the same way.

For anything unique in its behaviours, like a select, or a check_box, we’ll still lean on manually defining our methods. Those unique fields have their own api to interact with.

Breaking the first dependency moves us to reusable code, and breaking the second helps our builder be concise.

Let’s start with allowing changes to individual text_field calls. We’ll see how the wrap with structure, fill with style pattern in action.

Later we’ll include that behaviour on all 17 text-like fields.

Wrap with structure, fill with style

What makes the two forms different? What’s the same between them?

At its base, a form is just a collection of inputs that each point to a specific attribute we want to record. Regardless of the underlying model, there are only so many ways to capture the data on the page.

Input elements have a lot of overlap. We want all of our inputs to have a consistent look to them so they’re intuitive. They should all have the same font, border styling, color, etc.

Forms will appear in different parts of the user flow. A form with 20 inputs needs to be organized in a different way than a 3-input form. Layout is important for flow and usability, so each needs control of its own structure.

Appearance should be the same across different forms, while the layout should shift to accommodate the information being captured. Splitting up those two concerns gives consistent branding with dry CSS classes, with the flexibility to structure the layout on each form independently.

We intuitively use views this way. We expect a partial to hold the same shape wherever it is rendered. A partial innately has responsibility for structure. Giving the FormBuilder responsibility for appearance styling lets us pair FormBuilders with partial views to hit the sweet spot of dry code and functionality. The input fields appear consistently the same regardless of the form but will be shaped to fit a specific form’s need.

Our text_field method is currently doing both:

# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
    def text_field(method, options={})
      default_style = "mt-1 block w-full rounded-md shadow-sm focus:ring
        focus:ring-indigo-200 focus:ring-opacity-50"

      super(method, options.merge({class: default_style}))
    end
  end
end

Our structure styling: mt-1 block w-full

and appearance styling: rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50

Our next change: leave the appearance styling here, but lift our structure styling back up to the view.

Mix builder appearance styling with view-level structure styling

Our text_field interface allows us to pass in a model attribute and a hash of all our options.

Here’s what our view should look like:

form_with model: @adventurer do |f|
  f.text_field :name, class: "mt-1 block w-full"
end

In this example, we’re calling for a field tied to the name attribute on the @adventurer. Everything passed after name gets bundled into a hash.

To allow styling to be handled from both the view and the builder level, we need to isolate the class passed from the view with our structure styling. Then we need to combine it with the appearance styling declared in our builder.

# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
    def text_field(method, options = {})
      style_options, custom_options =
        partition_custom_opts(options)

      style = "rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
        + " #{style_options[:class]}"

      super(method, options.merge({class: style}))
    end

    CUSTOM_OPTS = [:class].freeze
    def partition_custom_opts(opts)
      opts.partition { |k, v| CUSTOM_OPTS.include?(k) }.map(&:to_h)
    end
  end
end

Partitioning out the :class from our other options gives us access to do just that. I’ve opted to use a template literal, so we have some safety if no class is given from the view.

Our resulting HTML in the browser has both the view-layer structure styling matched with the appearance styling from the builder:

<input class="rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50 mt-1 block w-full"
       type="text" value="Gandalf the Grey" name="adventurer[name]" id="adventurer_name">

One dependency broken!

If we apply a similar change to our labels, it allows our view to manage the structure only:

<%= form_with model: @adventurer do |f| %>
    <div class="md:flex md:w-full md:justify-between md:items-center mb-6">
      <%= f.text_field :name, class: "min-w-1/2" %>
      <%= f.label :name, class: "mb-1 md:mb-0 pr-4" %>
    </div>
<% end %>

The sample repo does some clever business to bundle up label creation within text_field and other helpers. Next, we’ll make changes, so we’re not manually writing all 17 text_like field helpers. That enables us to handle labels within the builder.

Onto making a concise implementation of all 17 text_like fields!

A pinch of metaprogramming

If you’re comfortable with ruby metaprogramming, this is the part where we’re shamelessly stealing from the Rails source for generating these methods. If you’re less comfortable, let’s talk about the purpose metaprogramming serves here and how we move our implementation toward utilizing it.

Under the hood, the ActionView::Helpers::FormBuilder utilizes metaprogramming to keep itself tidy. The whole list of input types is here if you’d like to look at them all. The ActionView FormBuilder bundles how it defines all the methods that behave like a text_field and, on initialization, generates an instance method to handle each type of input.

Our builder can mimic this behaviour to add our styling to all the text-like fields, then call for the same method on the ActionView FormBuilder we’re inheriting from to use the underlying functionality.

We’re going to adjust our implementation to:

  • use class_eval to generate methods for us
  • include two paths through every method; one for applying styling, one for pointing back to the default behaviour

Here’s the big hop toward that new approach:

module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder

    field_helpers - [:label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field].each do |field_method|
      class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
        def #{field_method}(method, options = {})
          if options.delete(:tailwindified)
            super
          else
            text_like_field(#{field_method.inspect}, method, options)
          end
        end
      RUBY_EVAL
    end

    def text_like_field(field_method, object_method, options = {})
      style_options, custom_options =
        partition_custom_opts(options)

      style = "rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
      + " #{style_options[:class]}"

      send(field_method, object_method, {
        class: style,
      }.compact.merge(opts).merge({ tailwindified: true }))
    end
  end
end

What changed? Here we:

  • call for field_helpers, which comes from ActionView::Helpers::FormBuilder
  • subtract a few methods from field_helpers that don’t behave like a text_field
  • add our class_eval loop, which handles our metaprogramming
  • rename our text_field method to text_like_field to show we’re handling many different types
  • adjust text_like_field to call send instead of super
  • introduce an option called tailwindifed to control our flow

class_evals, how do they work?

This class_eval method is almost lifted directly from the Rails source. Thank you, Rails gods.

By tapping into the metaprogramming aspect of Ruby, we can create all the methods that act like a text_field in a dry way. We’re creating a method for each text_like field that knows how to apply styling, then point to the default Rails behaviour for that input type.

field_helpers is defined on the parent class, ActionView::Helpers::FormBuilder. Our specific selection here

field_helpers - [:label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field]

creates an array of all the field_helpers that act the same way as our text_field input does.

Then we loop through our list and generate a method for each on our builder. When an instance of the TailwindFormBuilder is established, the object in memory will look like this:

module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
    # the class eval loop is here

    # def text_like_field ...

	def text_field(method, options={})
      if options.delete(:tailwindified)
        super
      else
        text_like_field(:text_field, method, options)
      end
	end

	def email_field(method, options={})
      if options.delete(:tailwindified)
        super
      else
        text_like_field(:email_field, method, options)
      end
    end
	# and so on, for all of the other 15 text-like field methods
  end
end

We’ve captured our previous code that partitions the class option and combines it with our default styling to reuse that functionality for every text-like field. We’re also letting each text field refer to the default Rails behaviour by calling super in the method. For example, ActionView::Helpers::FormBuilder has an email_field we can invoke by calling super in an email_field method on our own builder.

Where does tailwindified come into play?

I pulled a fast one on you. We’re doing some light recursion. Each method has two flows. The first invocation will add our styling and include a new key in the options hash called tailwindified.

Once that styling is applied, it calls for the original functionality on the default builder by calling super.

The flow goes like this:

  • We create a text_field in the view
  • Our custom builder’s text_field method is called
  • The text_field method calls text_like_field
  • text_like_field applies our styling and adds the tailwindified option
  • text_like_field uses send to call text_field again on our own TailwindFormBuilder
  • This time, text_field calls super, asking for the default Rails text_field behaviour and passing the options along.

By abstracting the name of the input type, and the two pathways, we’re able to tie into all of the input fields that behave similarly to text_input, style them, and keep our FormBuilder fairly concise.

This might be where you decide to get off the train! We’ve got the tie into FormBuilders down. We can leverage the wrap with structure, fill with style pattern I’ve been encouraging, and get a decent amount of value in doing some simple drying up of our Tailwind.

That said, I’ve got a couple more suggestions for ways you can lean into both this pattern and some clever tie-ins to Rails magic. Validation errors and labels!

Error styling

The reason I like this approach is we get to use our expected Rails API. If a user fails a form validation, we want to show them which field was incorrect with styling and possibly labels.

One major advantage of ActionView is getting to interact elegantly with our model and its built-in validations.

Here are a couple jumps that will bring our styling a bit closer to the sample app and give us a nice seam to respond to model errors.

module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder

    # no changes to the other stuff

    def text_like_field(field_method, object_method, options = {})
      style_options, custom_options =
        partition_custom_opts(options)

      style = "rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"

      classes = apply_style_classes(style, style_options, object_method)

      send(field_method, object_method, {
        class: classes,
      }.compact.merge(opts).merge({ tailwindified: true }))
    end

    def apply_style_classes(classes, style_options, object_method = nil)
      classes + border_color_classes(object_method) + " #{style_options[:class]}"
    end

    def border_color_classes(object_method)
      if errors_for(object_method).present?
        " border-2 border-red-400 focus:border-rose-200"
      else
        " border border-gray-300 focus:border-yellow-700"
      end
    end

    def errors_for(object_method)
      return unless @object.present? && object_method.present?

      @object.errors[object_method]
    end
  end
end

What changed? Now:

  • we store our appearance styling in a variable in text_like_field
  • text_like_field leans on another method for composing the whole style string
  • apply_style_classes took over blending structure styling from the view into the appearance styling our builder provides
  • a new border_color_classes method adds a red border to the input if errors are present on the model attribute
  • a helper method to check for errors on the object

Rails magic! If you’re using a flash[:error] on your form view, this helps capture the styling on the form to show users where your message relates.

Labels

If you poke at the sample project, you may note that text_like_field returns two things: labels + field. FormBuilder methods are only required to return HTML that attaches to our view, so we can bundle as many together as we’d like. The Rails docs use automatic labeling as an example for custom form builders.

We can provide wrap with structure, fill with style treatment to our label generation by making small adjustments:

  • Allow text_like_field to partition out label options, similar to our inline class
  • Adjust text_like_field to return both a label and the input element.

Partition

# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
    # ... the other things here

    CUSTOM_OPTS = [:class, :label].freeze
    def partition_custom_opts(opts)
      opts.partition { |k, v| CUSTOM_OPTS.include?(k) }.map(&:to_h)
    end
  end
end

The partition can grab both class and label from the options hash, passed from the view.

Programmatic labels

Now we can generate some labels from the options hash:

module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
    # ... no changes to the other things!

    def text_like_field(field_method, object_method, options = {})
      custom_opts, opts = partition_custom_opts(options)

      style = "rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"

      classes = apply_style_classes(style, custom_opts, object_method)

      field = send(field_method, object_method, {
        class: classes,
        title: errors_for(object_method)&.join(" ")
      }.compact.merge(opts).merge({tailwindified: true}))

      label = tailwind_label(object_method, custom_opts[:label], options)

      label + field
    end

    def tailwind_label(object_method, label_options, field_options)
      text, label_opts = if label_options.present?
        [label_options[:text], label_options.except(:text)]
      else
        [nil, {}]
      end

      label_classes = label_opts[:class] || "block text-gray-500 font-bold"
      label_classes += " text-yellow-800 dark:text-yellow-400" if field_options[:disabled]
      label(object_method, text, {
        class: label_classes
      }.merge(label_opts.except(:class)))
    end
  end
end

What changed?

  • text_like_field returns both an input, and a label for the input
  • we pass any label options provided to a helper method, tailwind_label
  • tailwind_label chooses defaults, so we safely do nothing if no label is provided at the view level
  • we apply the appearance style to the label and call for the default label method

Now our view can provide a label for input fields as an option.

<%= form_with model: @adventurer, builder: FormBuilders::TailwindFormBuilder, do |f| %>
  <div class="md:flex md:w-full md:justify-between md:items-center mb-6">
    <%= f.text_field :name, label: { text: "Full Name" } %>
  </div>
<% end %>

On its own, this feels like a small sidestep in functionality from the f.label approach, so I understand if it’s not particularly appealing to you. One thing it does enable is to add error labels to our form inputs using the same API.

module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
    # ... no changes to the other things!

    def text_like_field(field_method, object_method, options = {})
      custom_opts, opts = partition_custom_opts(options)

      style = "rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"

      classes = apply_style_classes(style, custom_opts, object_method)

      field = send(field_method, object_method, {
        class: classes,
        title: errors_for(object_method)&.join(" ")
      }.compact.merge(opts).merge({tailwindified: true}))

      labels = labels(object_method, custom_opts[:label], options)

      labels + field
    end

    def labels(object_method, label_options, field_options)
      label = tailwind_label(object_method, label_options, field_options)
      error_label = error_label(object_method, field_options)

      @template.content_tag("div", label + error_label, {class: "flex flex-col items-start"})
    end

    def error_label(object_method, options)
      if errors_for(object_method).present?
        error_message = @object.errors[object_method].collect(&:titleize).join(", ")
        tailwind_label(object_method, {text: error_message, class: " font-bold text-red-500"}, options)
      end
    end
  end
end

Overall this kinda feels clever to me. I don’t love being clever. I have a strong preference for writing explicit code and have a high tolerance for repetitively typing f.label in my views.

Ultimately I’ll leave it as an exercise to you to decide if the cleverness of auto-labeling is a win or not.

So much of it is context-dependent.

I’ll also leave the caveat that we might blend appearance/layout concerns with labels. I’m still reconciling that and need to try this pattern out on some larger projects before I give a final verdict.

However you form, form smoothly

Every time I sit down to write, I end up with a whole novel. So, thanks for sticking it out.

I’m not going to prescribe the error styling or label generation extensions for your forms. Your project will have its own unique qualities that will determine if those are relevant to you or not.

I do hope this dive into the way Rails builds forms and how wrap with structure, fill with style helps smooth out your experience building forms. Or even inspires you to do some more cool things! I haven’t even touched on things like Turbo or some of the deeper integration between forms and their underlying models. If you build a cool thing with this pattern, I’d love to see it!

Ultimately, I just want us all to have forms that serve us, feel intuitive, and don’t require us to spend more time than necessary on the cruft. This might not be the pattern for you or for your project, but maybe it’ll help.

Daniel Huss

Person An icon of a human figure Status
Double Agent
Hash An icon of a hash sign Code Name
Agent 00140
Location An icon of a map marker Location
Calgary, AB