The Slot Problem

rubyrailsbuilding-in-public

Slots are the hardest mapping problem in HERB.

ViewComponent slots are a Ruby concept: you define them with renders_one or renders_many in the component class, and you invoke them as method calls on a block variable that the component yields. A Card with a header and footer:

class CardComponent < ViewComponent::Base
  renders_one :header
  renders_one :footer
end

In ERB, you render it like this:

<%= render CardComponent.new do |card| %>
  <% card.with_header do %>
    <h2>Title</h2>
  <% end %>
  Some content
  <% card.with_footer do %>
    <small>Footer text</small>
  <% end %>
<% end %>

The block variable card is yielded by the component. Slots are method calls on that variable: card.with_header, card.with_footer. This is elegant Ruby, but it’s also deeply Ruby. There’s a yielded object, there are method calls on it, there are nested blocks. None of this maps naturally to HTML.

The colon convention

HERB’s answer is the colon syntax:

<Card>
  <Card:Header>
    <h2>Title</h2>
  </Card:Header>
  Some content
  <Card:Footer>
    <small>Footer text</small>
  </Card:Footer>
</Card>

<Card:Header> means “call the header slot on the enclosing Card component.” The colon separates the component name from the slot name. The compiler translates this to the card.with_header do ... end call that ViewComponent expects.

This was the first design that felt right. Alternatives I considered:

A <slot> element: <slot name="header">...</slot>. Too generic. It doesn’t tell you which component the slot belongs to. In a deeply nested template with multiple components, you’d have to count nesting levels to figure out which <slot> belongs to which component.

A directive: <div slot="header">...</div>. This is what Web Components do, but it ties the slot to a specific wrapper element. What if the slot content is text, or multiple elements? You’d need a wrapper div just to carry the directive.

The colon convention avoids both problems. <Card:Header> is unambiguous. It’s the Header slot on Card. It reads like a namespace, which is exactly what it is. And the closing tag </Card:Header> makes the scope visually explicit.

The block argument problem

Here’s where it gets tricky. Some ViewComponent slots yield an object back to the caller:

class TabsComponent < ViewComponent::Base
  renders_many :tabs, TabComponent
end

In ERB:

<%= render TabsComponent.new do |tabs| %>
  <% tabs.with_tab(title: "First") do |tab| %>
    <%= tab.title %> content
  <% end %>
<% end %>

The inner block receives tab, an instance of TabComponent that you can call methods on. In HERB, how do you name that block variable?

By default, HERB derives the variable name from the slot name. <Card:Header> gives you a block variable called header (underscored from the slot name). For most slots this is fine because you don’t even need to reference the block variable. You’re just wrapping content.

But when you do need it, we added the :with directive:

<Tabs>
  <Tabs:Tab title="First" :with="tab">
    {{tab.title}} content
  </Tabs:Tab>
</Tabs>

:with="tab" names the block variable explicitly. Without it, you’d use {{tabs_tab.title}} (auto-generated from the component and slot names), which is ugly.

This took a few iterations to get right. The first implementation accidentally deleted the :with directive during attribute processing because it was treated as a regular attribute and consumed before the slot compiler could see it. Then there was a period where :with on a component tag (not a slot tag) broke all the slot calls inside it, because the block argument naming logic was shared between components and slots and changing one affected the other.

The fix was separating the concerns: directives are parsed as attributes by the tokenizer but sorted into a separate directives hash at the AST level. The compiler checks directives independently of regular attributes. Components and slots each handle :with in their own code path.

What I learned

Template language design is a series of mapping problems. You have two worlds: the Ruby execution model (objects, blocks, yields, method calls) and the HTML authoring model (tags, attributes, nesting). You need to find a syntax that bridges them without losing the expressiveness of either.

Slots were the hardest bridge because they’re the most Ruby-specific concept. HTML doesn’t have slots. JSX doesn’t have slots (React uses children and props). ViewComponent slots are a unique Ruby pattern, yielded block arguments with method calls, and finding an HTML-like syntax that feels natural without hiding what’s actually happening underneath took more iterations than any other part of the language.

The colon convention and the :with directive are where we landed. They’re not perfect. The colon syntax doesn’t work for renders_many slots where you want to iterate, and the :with naming feels like a workaround rather than a first-class feature. But they’re good enough that writing slots in HERB is dramatically more readable than the ERB equivalent, and that’s the bar we’re trying to clear.