Back to All Posts

Conditionally Rendering ERB Templates from Different Directories with Ruby on Rails

A while back, I was working in a Ruby on Rails project in which we wanted to test out different versions of features within our application, most of which were housed in separate ERB partials.

For each feature, there’d be a default “experience”, but when a certain condition was met, we’d render an alternative, which might’ve contained slightly different markup, styles, or something else. To pull it off, we needed a way to maintain these different variants, as well as a means of reliably serving them whenever a particular experience was activated.

This scenario led me down the path of exploring conditional template rendering with the two most popular approaches to handling ERB templates Rails — the Action View module, and the View Component gem. Under more typical circumstances, we’d be able to leverage the “variants” feature offered by both solutions. But this was complicated by the fact that we wanted our variant templates to live in a different directory from our defaults (they wouldn’t be siblings). This post is mostly just a recap of how I prototyped this somewhat unusual need with both of these tools.

Conditional Rendering w/ Action View

Out of the two approaches I explored, Action View definitely requires the least amount of lift, being that I could largely rely on out-of-the-box functional`ity provided by Rails.

Template File Structure

First, a quick overview of the template organization scheme with which I started. The default feature template would live in the standard views directory), and then for each variant, a template by the same name would reside in a directory housed under views/experiences. For example, consider an application with a ProductController and a single index action:

class ProductController < ApplicationController    
	def index; end
end

And within the associated app/views/product/index.html.erb file, a _comparison-table.erb template is rendered:

<h1>Default Products Page</h1>

<%= render "comparison-table" %>

When navigating to that route in the browser, some sort of “default” experience would be rendered:

Now, imagine that “minimalist” and “maximalist” experiences were introduced for the application. Each of these experiences would have a dedicated directory (named according to that experience) that housed the alternative templates using the same folder structure (in this case, under the product namespace).

|-- app/
    |-- views/
        |-- experiences/
            |-- minimalist/
                |-- product
                    |-- _comparison-table.html.erb	
                    |-- index.html.erb
            |-- maximalist/
                |-- product
                    |-- _comparison-table.html.erb
                    |-- index.html.erb
        |-- product
	        |-- _comparison-table.html.erb
				|-- index.html.erb

While arguably a little more complicated than dealing strictly with sibling template variants, this still feel pretty maintinable. When a new experience is introduced, I’d simply duplicate the relevant template files and tweak away.

Overriding Where Templates Are Searched

At the start of this, I assumed we’d now need to build out some sort of abstraction on top of Rails’ standard render method in order determine which template to show. But shortly after digging in, I ran into the prepend_view_path method within the ActionView module.

This method is responsible for prepending paths in which Rails searches for templates to render. By default, that list of paths only contains app/views, but if you’d like Rails to look in another location first, you can configure that in a before_action hook. The following, for example, would tell any controller in an application to first look in the app/views/experiences/minimalist directory.

class ApplicationController < ActionController::Base
	before_action :prepend_experience_path
    
	def prepend_experience_path
		prepend_view_path "app/views/experiences/minimalist"
	end
end

Then, whenever the render method is used in an ERB template, the “minimalist” template would be pulled — no other special implementation or custom code necessary:

<!-- 
	First searches for:
    `app/views/experiences/minimalist/product/_comparison-table.html.erb` 
-->
<%= render "comparison-table" %>

Plus, this feature includes the implicit use of render directly within a controller action:

class ProductController < ApplicationController
	# First searches for:
	# `app/views/experiences/minimalist/product/index.html.erb`
    def index; end
end

The nice part about this is that it falls back gracefully. If no template is found in that path, Rails will move onto the remaining list of paths to check. Using that most previous example, if index.html.erb didn’t exist within the minimalist experience path, it’d fall back to app/views/product/index.html.erb — the default experience.

Setting a Particular Experience

At this point, I needed a means of determining which experience is active for a given user, and then use that experience to choose the directory that’s first searched for templates. There’s a gazillion ways this might be handled (the database, a third-party feature flag tool, etc.), but for simplicity of demonstration, I’ll use a ?experience=experience_name query string parameter in the URL. Here’s how that’d look in the inherited ApplicationController:

class ApplicationController < ActionController::Base
    before_action :prepend_experience_path

    # Only search in an "experience" directory if a query param is set in the URL.
    def prepend_experience_path
        prepend_view_path "app/views/experiences/#{experience}" if experience.present?
    end

private

	def experience
		params[:experience]
	end
end

On each request, the specified experience is pulled from the params object with our experience method. Then, I’m prepending that particular experience’s path. Following the earlier example, if I were to navigate to the same page with the experience set in the URL, the page would now render something like this:

Easy conditional template rendering… by just leveraging what Rails gives us anyway!

Conditional Rendering w/ ViewComponents

If you’re not that familiar with GitHub’s view_component gem, check it out. It toutes some pretty sick advantages — performance, testability, and encapsulation, to mention a few.

Unfortunately, conditionally rendering different templates with ViewComponents is a little more complicated than with Action View. As far as my digging through the code & documentation went, there’s neither (yet) a public API for controlling where .erb templates are searched, nor a blessed means of dictating which template should be rendered for a given component. Regardless, it’s possible to pull off for my specific use case. Here’s the approach I took:

An Example Component

I used a simple “ProductCard” component for trying this out, which comes with a ProducCardComponent class that accepts title, price, and experience (which we’ll use to determine the rendered template) parameters:

# /app/components/product_card_component.rb

class ProductCardComponent < ViewComponent::Base
    def initialize(title:, price:, experience: "")
        @experience = experience
        @title = title
        @price = price
    end
end

And then there’s the ERB template itself:

<!-- /app/components/product_card_component.html.erb -->

<div>
    <h4>Default Card</h4>
    Title: <%= @title %>
    <br />
    Price: $<%= @price %>
</div>

Inside an ERB template, that’s instantiated like so:

<!-- app/views/index.html.erb -->

<%= render(ProductCardComponent.new(title: "A Product", price: 49, experience: experience)) %>

Which ends up looking like this:

Taking Over Template Rendering

By default, ViewComponent looks for a sibling ERB file within the app/components directory. But, as an alternative, it’s possible to render content returned from a call method attached to the component class. And that can be used (or slightly hacked) to take full control over what’s rendered under certain conditions. Here’s how I started to wire that up:

class ProductCardComponent < ViewComponent::Base
    def initialize(title:, price:, experience: "")
        @experience = experience
        @title = title
        @price = price
    end

+   # Grab a template's contents and turn it into HTML.
+   def call
+   	template_contents = File.read(template_path)
+          
+       ERB.new(template_contents).result(binding).html_safe
+   end
+   
+private
+   
+   # Use the same template path ViewComponet uses by default.
+   def template_path
+       @template_path ||= "#{Rails.root}/app/components/#{self.class.name.underscore}.html.erb"
+   end
end

With this change, I’m manually compiling our own ERB templates using the included ERB renderer (note: that .result(binding) piece is important — it’ll allow access to instance variables from within the template). That template is being targeted by transforming the name of the current class into the same format expected by ViewComponent.

If you’re following along on your own and refresh the page at this point, you’d get an error. That’s because, out of the box, ViewComponent does not permit you to have both a template in your app/components directory, and a call method on your class. To get around this hurdle and allow the component’s templates to remain in the standard app/components directory, I did a little meta-programming (and no, I did not feel spectacular about it):

class ProductCardComponent < ViewComponent::Base
    def initialize(title:, price:, experience: "")
        @experience = experience
        @title = title
        @price = price

+       wipe_out_templates
    end

	def call
		template_contents = File.read(template_path)
        
        ERB.new(template_contents).result(binding).html_safe
    end
 
private
 
    def template_path
        @template_path ||= "#{Rails.root}/app/components/#{self.class.name.underscore}.html.erb"
    end

+   # Fake ViewComonent into thinking there are no templates in `app/components`.
+   def wipe_out_templates
+       self.class.class_eval do
+           def self._sidecar_files(_extensions)
+               []
+           end
+       end
+   end
end

With this in place, each time the component is instantiated, the static _sidecar_files method is overridden (a class method that literally has the word “EXPERIMENTAL” above it in the source, lol). That override stomps out its original implementation, making the library believe that there are actually no ERB templates living in the directory. A little dirty-feeling, but still. Onward!

Conditionally Rendering Templates

Next up, I needed to introduce the logic for choosing a different template when a certain experience is active. To do that, I modified the template_path method and include two more:

def template_path 
    @template_path ||= File.exist?(experience_template_path) ? experience_template_path : standard_template_path
end

def experience_template_path
    @experience_template_path ||= "#{Rails.root}/app/components/experiences/#{@experience}/#{self.class.name.underscore}.html.erb"
end

def standard_template_path
    @standard_template_path ||= "#{Rails.root}/app/components/#{self.class.name.underscore}.html.erb"
end

Now, the component will first always check to see if it can render a template based on whatever experience is set (which might be none). If it can’t, the “standard” template will be used.

A Bit of Clean-Up

For housekeeping purposes, this can be arranged differently for better reuse among all components that might be introduced. To do that, I created a new ExperienceableComponent class from which all of my components extend, which will be responsible for containing all of this custom rendering logic. Altogether, it looked like this:

class ExperienceableComponent < ViewComponent::Base
    def initialize(*)
        wipe_out_templates
    end

    def call
        template_contents = File.read(template_path)
        
        ERB.new(template_contents).result(binding).html_safe
    end

private 

    def template_path 
        @template_path ||= File.exist?(experience_template_path) ? experience_template_path : standard_template_path
    end

    def experience_template_path
        @experience_template_path ||= "#{Rails.root}/app/components/experiences/#{@experience}/#{self.class.name.underscore}.html.erb"
    end

    def standard_template_path
        @standard_template_path ||= "#{Rails.root}/app/components/#{self.class.name.underscore}.html.erb"
    end

    def wipe_out_templates
        self.class.class_eval do
            def self._sidecar_files(_extensions)
                []
            end
        end
    end
end

After that abstraction, the actual component class can look a lot simpler:

class ProductCardComponent < ExperienceableComponent
    def initialize(title:, price:, experience: "")
        @experience = experience
        @title = title
        @price = price

        super
    end
end

With things feeling a little tidier, navigating to the same page with ?experience=minimalist in the URL renders exactly what I wanted:

And there you go. Despite the slight hackiness, we got it running!

Rails Developers are Smart

If there’s anything I really came to realize during all of this exploration, it’s that there’s a ton of considerations the Action View & ViewComponent contributors have balanced with excellence as they built (and continue to build) out these UI-rendering solutions. I can’t imagine building out a package of such scale that satisfies so many different use cases, including my own. But they did it. So, a brief message to those contributors: You’re a lot smarter than I am. But I’mma catch you.

Thanks for tagging along!


Alex MacArthur is a software engineer working for Dave Ramsey in Nashville-ish, TN.
Soli Deo gloria.

Get irregular emails about new posts or projects.

No spam. Unsubscribe whenever.
Leave a Free Comment

1 comments
  • Aaric

    You should also check out the Rails variants stuff, https://guides.rubyonrails.org/layouts_and_rendering.html#the-variants-option. It might work well for this. Looks like ViewComponents support it as well, https://viewcomponent.org/guide/templates.html.


    1 reply
    • Alex

      I'm havin' you proofread my Ruby posts from this moment forward. 😅😂

      Realizing that "variants" feature early on definitely would've influenced how we decided to structure our templates. Regardless... I'm now a master at a very specific template rendering need.