Hacking Rails controller actions and rendering

Override the method_for_action method in Rails controllers to build bulk actions, render views in new ways, and make your code more secure and maintainable

A common pattern Rails developers rarely have to think about is how their routes map to controller actions because it works so well and requires so little code. Rails is smart enough to know that the get "/posts/:id", to: "posts#show" routes to the ./views/posts/show.html.erb template. We also get nice macros like resources :posts so there’s even less code we have to write.

Have you ever wondered how Rails handles this magic? Did you know that it’s very simple method that we can override to do some pretty nifty things in our Rails applications? In this article, we’ll explore how the method_for_action can be used in Rails controllers to make them more readable and maintainable.

How implicit rendering works in Rails

Before we start hacking, let’s take a look at how Rails knows how to render the ./views/posts/show.html.erb template when somebody visits /posts/1 and the action isn’t defined in the controller–it all centers around an inconspicuous method named method_for_action. There’s not much to it.

def method_for_action(action_name)
  super || if template_exists?(action_name.to_s, _prefixes)
             "default_render"
           end
end

When a request is dispatched to a Rails controller, it asks the controller, “Hey, before I dispatch this action, what method should I call to determine what method I should call on the controller?”

The super method calls up to the superclass’s method_for_action method, which checks if a method for the action was explicitly defined on the controller. If not, it then calls template_exists? to see if a view template exists for the action.

If a template is found, the "default_render" string is returned, which Rails uses to call on the controller. The default_render method checks the ./app/views directory to see if templates exist for the requested action, variant, and format.

def default_render
  # Look for a view template that exactly matches the requested format.
  if template_exists?(action_name.to_s, _prefixes, variants: request.variant)
    render
  else
    # ... Error handling code
  end
end

If the template exists, Rails will render the template. If not, it will raise an error. This is the default behavior of Rails, but you can override it to do something else. There’s more code than shown above, which you can read in the implicit_render.rb Rails source code.

It’s a level of indirection, but it’s what makes implicit rendering possible. Let’s see how Superview, a component-based rendering library for Rails, hacks into the method_for_action method to render Ruby component classes instead of templates.

Superview routes actions to Phlex or ViewComponent classes

There are a few key pieces that make implicit rendering work in Superview. Understanding how these work will both help you better wrap your head around Superview and open your brain up to other rendering possibilities in Rails.

The magic happens in method_for_action

Let’s start with the method_for_action controller method since this is something very few Rails developers encounter.

# From the Superview gem, overrides the method_for_action method
def method_for_action(action_name)
  super || if component_action_exists? action_name
             "default_component_render"
           end
end

Rails calls the method_for_action method to determine what method it should call when it dispatches an action to controller code. For example, if we are looking at the posts#show action, Rails will call this method and pass the string "show" as the argument.

Remember from above that super checks if a PostsController#show method is explicitly defined on the controller. If not, it then looks in the ./app/views/posts for a corresponding show.*.* template. This is done first to preserve the existing Rails behavior of looking for view templates in the ./app/views directory, which means you can use Superview in existing Rails applications without changing any of your existing views.

Since we’re not using any of that, Rails finally calls the component_action_exists? method, which checks to see if the PostsController::Show constant exists.

# From the Superview gem, checks if a class for the action is present
def component_action_class(action:)
  action_class = action.to_s.camelcase
  const_get action_class if const_defined? action_class
end

If the constant is found in the controller for the action, Rails is instructed to call the default_component_render method to render the view, which is where the component is finally rendered.

def default_component_render
  render component
end

In this case, the component method returns an instance of a class that responds to #render_in, which could be a Phlex or ViewComponent instance.

That all seems a bit esoteric, and it kind of is, but understanding the method_for_action method opens the doors to lots of other powerful abstractions, like building controllers that handle bulk updates depending on which submit input button was clicked.

Bulk actions via submit input buttons

To illustrate the variety of things you can do with the method_for_action method, let’s look at how you can use it to handle bulk actions in a controller. This is a common pattern in Rails applications where you have a form with multiple submit buttons that perform different actions.

Let’s start with the view, which might look something like this:

<%= form_with url: posts_batches_path, method: :patch do |form| %>
  <ul>
    <% @posts.each do |post| %>
      <li>
        <%= form.check_box name: "batch[id][]", value: post.id %>
        <%= post.title %>
      </li>
    <% end %>
  </ul>

  <!-- These buttons correspond with the actions in the controller -->
  <%= form.submit "Publish",
    name: "batch[action]",
    value: "publish" %>

  <%= form.submit "Unpublish",
    name: "batch[action]",
    value: "unpublish" %>

  <%= form.submit "Delete",
    name: "batch[action]",
    value: "delete" %>
<% end %>

The form above sends a PATCH request to the posts_batches_path with an array of post ids and the action to perform. We’ll know which action to perform based on the value of the submit button the user clicks.

Here’s what a typical controller looks like in Rails that handles bulk actions.

# ./app/controllers/posts/batches_controller.rb
class Posts::BatchesController < ApplicationController
  def update
    @batch = current_user.posts.find(*params.dig(:patch, :id))

    case params.dig(:batch, :action)
    when "publish"
      @batch.update_all(published_at: Time.now)
    when "unpublish"
      @batch.update_all(published_at: nil)
    when "delete"
      @batch.destroy_all
    end
  end
end

It is kind of weird how we have to dispatcher inside of the dispatcher. Wouldn’t it be better if we could define a method for each bulk operation on the controller? We can do that with the method_for_action method.

# ./app/controllers/posts/batches_controller.rb
class Posts::BatchesController < ApplicationController
  before do
    @batch = current_user.posts.find(*params.dig(:patch, :id))
  end

  def publish
    @batch.update_all(published_at: Time.now)
  end

  def unpublish
    @batch.update_all(published_at: nil)
  end

  def delete
    @batch.destroy_all
  end

  protected

  def method_for_action(action_name)
    super || if batch_method_defined?
              batch_action
            end
  end

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

  def batch_method_defined?
    public_method_defined? batch_action
  end
end

This can further be cleaned up by moving the method_for_action method to a concern named BatchActions.

# ./app/controllers/concerns/batch_actions.rb
module BatchActions
  included do
    protected

    def method_for_action(action_name)
      super || if batch_method_defined?
                 batch_action
               end
    end

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

    def batch_method_defined?
      public_method_defined? batch_action
    end
  end
end

Then include the concern in any controller that’s goind to handle bulk actions.

# ./app/controllers/posts/batches_controller.rb
class Posts::BatchesController < ApplicationController
  include BatchActions

  before do
    @batch = current_user.posts.find(*params.dig(:patch, :id))
  end

  def publish
    @batch.update_all(published_at: Time.now)
  end

  def unpublish
    @batch.update_all(published_at: nil)
  end

  def delete
    @batch.destroy_all
  end
end

Ahh, that looks better and cleaner! It goes further than just “clean code” though; you can add before_action hooks for authorization, logging, or other concerns that are shared between the bulk actions, the rescue_from method can be used to handle errors that are raised in the bulk actions, and testing should be a bit more straightforward.

Conclusion

The method_for_action method is a powerful tool in Rails that allows you to customize how Rails dispatches actions to controller code. By overriding this method, you can clean up your controller code, make it more maintainable, and add powerful abstractions to your application.

Not only can new ways of rendering be developed, but exposing more of your application logic as actions means you can tie into it more clearly via before_action hooks, rescue_from error handling, and testing. Pundit policies are much easier to tie into bulk actions when the dispatcher is moved out of a case statment and into the controller itself.

What will you build with the method_for_action method in your Rails applications?

Support this blog 🤗

If you like what you read and want to see more articles like this, please consider using Terminalwire for your web application’s command-line interface. In under 10 minutes you can build a command-line in your favorite language and web framework, deploy it to your server, then stream it to the Terminalwire thin-client that runs on your users desktops. Terminalwire manages the binaries, installation, and updates, so you can focus on building a great CLI experience.