Rails Partials with Ruby Methods

Rails is on a path to re-implementing Ruby methods in an attempt to make partials easier for developers to reason through. Here’s a tweet from Rails on May 16, 2024.

Screenshot of tweet

Just for fun I took a swing at moving partials into Ruby helpers so a plain ‘ol Ruby method signature could be used to enforce the boundary between the caller and the template.

Defining partials

Here’s what a partial looks like that sits between a helper method and any other part of Rails.

module ApplicationHelper
  def contact_info(first_name, last_name = "Brad", email:, phone: nil)
    phone ||= "911"

    render {
      <<~ERB
        <p>Hi <%= first_name %> <%= last_name %>,</p>

        <p>Here's your contact info:</p>

        <dl>
          <dt>Email:</dt> <dd><%= email %></dd>
          <dt>Phone:</dt> <dd><%= phone %></dd>
        </dl>

        <p>That's it, thanks for playing!</p>

        <p>Served from <%= request.url %> at <%= @time %></p>
      ERB
    }
  end
end

Calling partials

This partial can be called from anywhere in the Rails app as follows:

<h1>Let's look at a minimum viable way to improve partials</h1>

<%= contact_info("Brad", "Gessler") %>

Now let’s render the thing.

Ruby keyword error

Oops! I forgot to pass the email keyword argument. That’s the feature: we used Ruby’s method definitions to enforce what data is passed into the template. Let’s try that again.

Correct call into Ruby method renders the template

Ahh, that’s better. Now let’s see what makes this possible.

Implementation

The implementation of this helper ended up being around 10 lines of code, mostly to deal with the gymnastics of passing the variable bindings from the helper function into the Rails renders’ locals.

module ApplicationHelper
  def render_partial(*, locals: {}, **, &block)
    binding = block.binding
    block_locals = binding.local_variables.each_with_object(Hash.new) do |variable, locals|
      locals[variable] = binding.local_variable_get variable
    end
    render *, inline: block.call, locals: block_locals.merge(locals), **
  end

  def render(...)
    block_given? ? render_partial(...) : super(...)
  end
end

If it looks confusing, that’s because it kind of is! The challenge for this whole problem is getting the signature from the helper method:

def contact_info(first_name, last_name = "Brad", email:, phone: nil)

into the locals that’s passed into Rails renderer. The only way of accomplishing that without explicitly passing the helper functions binding into render is to put an ERB string (or whatever template) into the render {} block.

Going further

This works with any Rails templating language. To make this work with Slim, you might do something like this:

module ApplicationHelper
  def more_info(name:)
    render(type: :slim) {'''
      h1 Hello there!
      p This is for #{name}
    '''}
  end
end

Of course you could just use Phlex 💪 and not have to deal with these global scoping issues at all.