justin․searls․co

Instantiate a custom Rails FormBuilder without using form_with

I'm building a Rails app using Tailwind, which works really well for almost everything out-of-the-box except forms, because—unless you relish the idea of repeating the exact same list of CSS classes for each and every field in your app—you're going to be left wanting for some way to extract all that messy duplication.

To that end, I've gradually built up a custom FormBuilder to house all my classes. (If you're looking for a starting point, here's a gist of what my TailwindFormBuilder currently looks like).

This works great when you're using form_with, because the custom form builder will automatically take over when you set the builder option:

<%= form_with model: @user, builder: TailwindFormBuilder do |form| %>
<% end >

And if you set it globally with ActionView::Base.default_form_builder = FormBuilders::TailwindFormBuilder, the custom builder becomes the default. Nifty!

But what about when you need to render input elements outside the context of a proper form? Today, I wanted to render some checkboxes for a client-side UI that would never be "submitted" and for which no object made sense as an argument to form_with. Both immediately-available options are bad:

  1. Wrapping those checkboxes in an unnecessary <form> tag by passing a dummy object to form_with, just for the side effect of having my TailwindFormBuilder invoked, seemed kind of silly
  2. Using one of Rails' built-in form helpers that work outside form_with, like check_box_field, wouldn't invoke my TailwindFormBuilder and would therefore lack any of its CSS classes

Instead, I figured the best path forward would be to instantiate my form builder myself, even though that's not something the docs would encourage you to do to. So, I pulled up the FormBuilder#initialize source to see what arguments it needed:

def initialize(object_name, object, template, options)
  # …
end

Lucky for us, the only thing that really matters above is the template object, which I (correctly, apparently) guessed could be passed as self from an ERB file or a view helper.

Here's a little helper I made to instantiate my custom TailwindFormBuilder manually:

# app/helpers/faux_form_helper.rb

module FauxFormHelper
  FauxFormObject = Struct.new do
    def errors
      {}
    end

    def method_missing(...)
    end

    def respond_to_missing?(...)
      true
    end
  end

  def faux_form
    @faux_form ||= FormBuilders::TailwindFormBuilder.new(
      nil,
      FauxFormObject.new,
      self,
      {}
    )
  end
end

Explaining each of these arguments in order:

  1. object_name is set to nil, so the name attribute of each input isn't wrapped in brackets (e.g. name="some_object_name[pants]")
  2. object doesn't matter, because this isn't a real form, so my FauxFormObject just responds as if every possible value is a real property, as well as to errors with an empty hash (which my form builder uses to determine when to highlight validation problems in red)
  3. template is set to self, because that seems to work
  4. options is left as an empty hash, because I don't appear to depend on any yet

This, in turn, lets me render consistently-styled form fields anywhere I like. As a result, this ERB:

<%= faux_form.check_box(:pants, checked: true) %>

Will render with all my default checkbox classes:

<input
  type="checkbox" value="1" checked="checked" name="pants" id="pants"
  class="block rounded size-3.5 focus:ring focus:ring-success checked:bg-success checked:hover:bg-success/90 cursor-pointer focus:ring-opacity-50   border border-gray-300 focus:border-success"
>

This incongruity has been a pebble in my shoe for a couple years now, so I'm glad to finally have a working solution for rendering Tailwind-ified fields both inside and outside the context of a proper HTML form.

Hope you find it helpful! 🦦🪄


Got a taste for hot, fresh takes?

Then you're in luck, because you can subscribe to this site via RSS or Mastodon! And if that ain't enough, then sign up for my newsletter and I'll send you a usually-pretty-good essay once a month. I also have a solo podcast, because of course I do.