Rendering from a Model with ActionView::Base.new => WAT?

I’m a firm believer in the “make it work” philosophy - solve problems first, then refactor. That said, my team may have gotten a little too creative making our last project work. Just take a look at this gnarly method we cooked up:

class Recipe < ActiveRecord::Base
  def recipe_card
    ActionView::Base.new(
      Rails.configuration.paths['app/views']
    ).render(
      partial: 'menus/recipe',
      format: :txt,
      locals: { recipe: self }
    )
    ###🙊🚷 W A T ☠😿###
  end
end

Yep. We instantiated a new instance of ActionView::Base in a model. And used it to render a view outside a controller. WAT?!

Before I go any further, let’s take care of the necessaries:

  • Yes, we know what we did was wrong.
  • Yes, we are deeply sorry for our crimes against MVC.
  • Yes, we’ll refactor and never repeat this grievous offense again.

But the thing is, it worked. And I’d like to explore how and why before purging this from our git history moving forward.

THE PROBLEM

The problem we were trying to solve was relatively simple. In our Menu views, we’d created a partial _menu that contains multiple collection_check_boxes fields. Each checkbox renders another partial _recipe, which serves up an image and a link. The end result is a row of recipe cards, where each card has an image, a link, and a checkbox that users can tick to select that recipe for their final menu:

Approvable Feast recipe cards

After reviewing the documentation on collection_check_boxes, we thought we’d be able to pass the render method inside the &block argument.

# ActionView::Helpers::FormOptionsHelper#collection_check_boxes
collection_check_boxes(
  object, method, collection, value_method, text_method, options={}, html_options={}, &block
)

Unfortunately, no matter what we tried, we couldn’t get that _recipe partial to render. We tried passing HTML options to the label and check_box builder methods. Nothing. We tried utilizing the “special” object, text, and value methods - no dice.

Ultimately, we kept circling back to the text_method argument. In most of the examples we found online, text_method called a method defined in the form’s associated model. This got us thinking - could we write a method in the Recipe model that would render the _recipe partial? That way we could pass it as an argument into collection_check_boxes and boom, problem solved.

THE WAT SOLUTION

Turns out with a little hackery, you can render a view from a model. Let’s follow the pass-and-catch below.

☠ Start in the menu partial, where collection_check_boxes :recipe_card argument calls the Recipe model’s method #recipe_card.

<!-- menu partial -->
<%= form_for @dinner.menu, method: "PATCH", remote: true do |f|%>
  <div id="appetizers">
    <h3>Appetizers</h3>
    <div class="recipe-card horizontal-scroll">
      <%= f.collection_check_boxes(:recipes, @dinner.menu.appetizers, :id, :recipe_card) %>
    </div>
  </div>
  <%= f.submit "Set Menu", class: "btn btn-primary" %>
<% end %>

☠ Inside Recipe, #recipe_card instantiates a new instance of ActionView::Base and calls #render on it, passing in the recipe partial, :format, and local variables - in this case, the current instance of Recipe.

class Recipe < ActiveRecord::Base
  def recipe_card
    ActionView::Base.new(
      Rails.configuration.paths['app/views']
    ).render(
      partial: 'menus/recipe',
      format: :txt,
      locals: { recipe: self }
    )
  end
end

☠ Nothing strange happening in the recipe partial. We have access to an instance of Recipe, thanks to the locals we passed in, so the partial serves up the recipe’s image, short_name, and link, blissfully unaware of the oddness that’s allowing it to do so.

<!-- recipe partial -->
<div class="recipe-card-partial">
  <div class="recipe-card-partial-image">
    <img src="<%= recipe.image_upload.url %>" class="img-thumbnail">
    <%= link_to recipe.short_name, "/recipes/#{recipe.id}"%>
  </div>
</div>

THE WHY

Let’s break down exactly what’s happening in that #recipe_card method.

def recipe_card
  ActionView::Base.new(
    Rails.configuration.paths['app/views']
  ).render(
    partial: 'menus/recipe',
    format: :txt,
    locals: { recipe: self }
  )
end

First up, let’s inspect ActionView::Base.new(args). If you check the source code, you’ll see that the first argument passed to ActionView::Base#initialize is context (line 185). The context we’re passing here is Rails.configuration.paths['app/views'], which selects the app/views path object from our application. Anyone curious about what that path object looks like can check it out here (spoiler alert: it’s a doozy). Note: Since we’re instantiating outside the controller, we have to explicitly configure our view paths, otherwise our new ActionView instance won’t have access to them.

Moving on, as context travels through the #initialize method, it’s eventually passed into ActionView::Renderer (in line 195, in our case), which parses it into the ultimate return value from #initialize. We then call #render on that return value, passing it :partials and :locals arguments. That #render method is the “main render entry point shared by ActionView and ActionController”, and it taps ActionView::PartialRenderer to take care of the rest of the work.

Under normal circumstances, controllers instantiate new instances of the ActionView::Base class, so the process described above would be triggered by calling #render in the controller. However, by initializing ActionView::Base in the model, we get to define our own context and bypass the normal ActionController > ActionView > render template flow. That allows our model method #recipe_card to serve up an instance of that view whenever and wherever it’s called. Wild.

WAT DID WE LEARN

Main takeaway: while it’s not too hard to override Rails’ MVC conventions, it’s almost always a bad idea. First off, it introduces unnecessary complexity into your code base. Look, it took me 3 paragraphs to break down what was happening in that #recipe_card method. Imagine if we’d pulled this same trick other places in our code. Good luck to new team members trying to grok / refactor that…

Also, if you find yourself trying to override Rails’ built-in conventions, that’s probably a good sign you’re approaching your problem the wrong way. I’m not José Valim; there’s probably a simpler solution. Likely somewhere in the collection_check_boxes source code, which is where I’m heading next…


For the record:
We’re not the only rogues who’ve tried to pull this caper. I’ve linked to a few other offenders below, including the render_anywhere gem, which we briefly considering using. Our solution seems like a similar (but slightly simpler) version of the gem’s. For example, the gem sets up a dummy controller, RenderingController, and uses it to override Rails’ built-in render method. Since we only needed this unconventional behavior in one place, we opted to write our own weird method, rather than utilize a third-party library.

Thanks for reading, and feel free to add feedback / corrections in the comments below!

More resources:

  1. Alvin Liang => Render from model in Rails 3
  2. render_anywhere gem from Luke Melia of YappLabs
  3. The Devel! blog => Rails: Calling render() outside your Controllers
Written on April 26, 2015