Hacking Rails Implicit Rendering for View Components & Fun

Ruby gem trapped inside of a jigsaw puzzle looking at a bunch of numbers
Image by Annie Ruygt

Understanding how Rails handles requests from routes.rb to calling the action method on a controller makes it possible to build Rails plugins ranging from Hanami-like action classes to component-driven Rails development.

Have you ever opened a Rails controller that looks like this and wondered how it knows to render the view ./app/views/show.html.erb?

class PostsController
  before_action :load_post

  # How does Rails know to render ./app/views/show.html.erb
  # when I don't write the `#show` method in my controller?

  private

  def load_post
    @post = Post.find(params[:id])
  end
end

Rails has a method called method_for_action that Rails calls to perform this magic. At first glance it doesn’t seem like much, but it turns out we can do some pretty nifty things with this method, like build a Rails application entirely from components using nothing but Phlex classes.

Replace def show with class Show

Paste the code snippet in the controller concerns directory at ./app/controller/concerns/phlexable.rb and you’ll be able embed Phlex views in your application controllers with the name Show, Edit, etc. to handle requests from show, edit, etc. respectively.

Let’s have a look at the entirety of the code.

module Phlexable
  extend ActiveSupport::Concern

  class_methods do
    # Finds a class on the controller with the same name as the action. For example,
    # `def index` would find the `Index` constant on the controller class to render
    # for the action `index`.
    def phlex_action_class(action:)
      action_class = action.to_s.camelcase
      const_get action_class if const_defined? action_class
    end
  end

  protected

  # Assigns the instance variables that are set in the controller to setter method
  # on Phlex. For example, if a controller defines @users and a Phlex class has
  # `attr_writer :users`, `attr_accessor :user`, or `def users=`, it will be automatically
  # set by this method.
  def assign_phlex_accessors(phlex_view)
    phlex_view.tap do |view|
      view_assigns.each do |variable, value|
        attr_writer_name = "#{variable}="
        view.send attr_writer_name, value if view.respond_to? attr_writer_name
      end
    end
  end

  # Initializers a Phlex view based on the action name, then assigns `view_assigns`
  # to the view.
  def phlex_action(action)
    assign_phlex_accessors self.class.phlex_action_class(action: action).new
  end

  # Phlex action for the current action.
  def phlex
    phlex_action(action_name)
  end

  # Try rendering with the regular Rails rendering methods; if those don't work
  # then try finding the Phlex class that corresponds with the action_name. If that's
  # found then tell Rails to call `default_phlex_render`.
  def method_for_action(action_name)
    super || if self.class.phlex_action_class action: action_name
               "default_phlex_render"
             end
  end

  # Renders a Phlex view for the given action, if it's present.
  def default_phlex_render
    render phlex
  end
end

The more interesting bit in this concern is method_for_action, which is what drives Rails “implicit rendering”.

Let’s break it down.

Fly.io ❤️ Rails

Fly.io is a great way to run your Rails app close to your users. It’s really easy to get started. You can be running in minutes.

Deploy a Rails app today!

ActionController::ImplicitRender#method_for_action

This is a method in Rails that Rails asks, “what method should I call to render the action given to me by the router?”. At first you might be thinking, “that’s easy! If I route to posts#index just call index! Yes, but have you ever wondered how Rails finds ./app/views/posts/index.erb if you don’t define an index method? It does so through method_for_action.

For the Phlex component renderer, I call super first, which is Rails default rendering stack. If super returns nil, meaning no methods or view templates were found to render the requested action, I check if the Phlex class exists in my controller. If the class does exist, I return the string "default_phlex_render", which Rails then calls to render the action.

# Try rendering with the regular Rails rendering methods; if those don't work
# then try finding the Phlex class that corresponds with the action_name. If that's
# found then tell Rails to call `default_phlex_render`.
def method_for_action(action_name)
  super || if self.class.phlex_action_class action: action_name
    "default_phlex_render"
  end
end

default_phlex_render

method_for_action returned the "default_phlex_render" string, which Rails then calls via self.send("default_phlex_render") to create an instance of the Phlex view and pass it to Rails built-in render method.

# Renders a Phlex view for the given action, if it's present.
def default_phlex_render
  render phlex
end

The phlex method returns an instance of a Phlex class for the requested action.

# Initializers a Phlex view based on the action name, then assigns `view_assigns`
# to the view.
def phlex_action(action)
  assign_phlex_accessors self.class.phlex_action_class(action: action).new
end

# Phlex action for the current action.
def phlex
  phlex_action(action_name)
end

action_name is an Action Controller method that returns the action as resolved by the router. For the sake of example, let’s say we’re requesting /blog/1/posts—the Rails router might resolve that to the PostsController controller and the index action name. The "index" string gets passed into phlox_action method, which it turns into a class name Index to check if it exists at PostsController::Index via the const_get method.

def phlex_action_class(action:)
  action_class = action.to_s.camelcase
  const_get action_class if const_defined? action_class
end

If the class does exist on the controller, an instance of it is created and then we assign the instance variables from the controller into the Phlex view class only if it has a setter defined.

This method could be modified to also look for views in other class hierarchies, like View::Posts::Index, for example.

def assign_phlex_accessors(phlex_view)
  phlex_view.tap do |view|
    view_assigns.each do |variable, value|
      attr_writer_name = "#{variable}="
      view.send attr_writer_name, value if view.respond_to? attr_writer_name
    end
  end
end

Finally we copy the instance variables that were set in the controller into the class, but only if the Phlex view component has a setter method that matches the instance variable name. For example, @posts in the controller would set Index#posts=. Why not simply copy the instance variables from the controller into the Phlex view? Because it make the component leaky and harder to test since it breaks encapsulation. Think about it, would you rather setup a view component test like this?

# Eww, nobody wants to initialize a view this way
index = Index.new
index.instance_variable_set("@blogs", Blog.all)

Or like this?

# That's a little better
index = Index.new
index.blogs = Blog.all

The latter is easier to test and is preferred, but if for whatever reason you wanted to copy over all the instance variables directly into the class from the controller, now you know how to do it.

Taking it further

In this example we used Phlex classes to render Rails views, but now that you know how to dispatch a request to a class embedded in a Rails controller, there’s lots that could be done including:

ViewComponent integration

If the ViewComponent library is your cup of tea, the same technique used to render Phlex views could be applied to the ViewComponent gem.

Action classes

If you have code that’s heavy on before_action and after_action calls and inheriting ActionControllers doesn’t make sense, it’s possible to build Hanami-style action classes that can be nested in an Action Controller. It’s not 100% the same thing, but maybe its an abstraction you need for your application.

More sophisticated Phlex view class lookups

If you decide to build a Rails app entirely from Phlex components, you could look up constants in different name spaces like Views::#{controller.name}::#{controller.action_name}, or break it out into formats like Views::#{controller.name}::#{request.format}::#{controller.action_name}.

Format classes

If you’re constantly implementing format.html, format.json blocks in your application, you could replace them with a responder class.

class ApplicationResponder
  attr_accessor :model, :controller

  delegate :action_name, :render, to: :controller

  def html
    render action_name
  end

  def json
    @model.as_json
  end
end

Batch resource manipulation

Handling batches of resources in Rails in a secure manner can get awkward because Rails wants each action to have its own URL, but rendering an HTML form with a list of check boxes next to each item in a form results in a request payload that looks like this:

{
  batch: {
    selected: [1,2,3,4,5,6,7],
    action: "delete
  }
}

method_for_action can match params[:batch][:action] to the correct action in our batch controllers and make sure the endpoints are publicly accessible routes with this little snippet of code:

module Batchable
  extend ActiveSupport::Concern

  protected

  def method_for_action(action_name)
    routable_batch_action? ? batch_action : super
  end

  def batch_action
    params.dig("batch", "action")
  end

  def routable_batch_action?
    self.class.action_methods.include? batch_action
  end
end

Which we connect to our routes via this nifty little helper.

Rails.application.routes.draw do
  resources :blogs do
    nest :posts do
      batch :delete, :publish
    end
  end
end

There’s a more that goes into batch, which I’ll cover in a future post.