The one with the Ruby DSL for UI components

In a previous post I talked about the internal design system I have been working on recently, consisting of server-rendered UI components, using Ruby and ERB templates.
Most of these components are simple: you call a method and the page renders the HTML returned by the corresponding template.
Some examples of components:
primary_button "Save"
status_badge "Finished", color: "green"
text_input name: "name", label: "Name", value: "David"
dropdown name: "country", label: "Country", options: countries
Of course, not all components are that simple.
The problem
The first complex component I had to create was a table. I mentioned in the previous post that my first approach was to create a CSS-only component:
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>[email protected]</td>
</tr>
</tbody>
</table>
I soon realized that a component of that kind is not really useful. While it is true that defining styles is very simple this way, adding common behavior for the different applications that use it, such as making the content of the different columns sortable, is not.
The first block DSL component: table
The goal was to create a table component that would allow consumers to write something like the following inside a single <%= %> tag:
table do
table_head do
table_row do
table_header_cell("Name", **sortable_column_params(:name, sorting_params:))
table_header_cell("Email", **sortable_column_params(:email, sorting_params:))
end
end
table_body do
table_row do
table_cell("Alice")
table_cell("[email protected]")
end
end
end
Where **sortable_column_params would return a hash containing the required configuration for that column to be properly sorted:
{ sortable: true, sort_direction: nil | "asc" | "desc", htmx: { ... } }
This is harder than it looks. In a plain Ruby method, each helper runs, returns a string, and the outer block has no visibility into what its children did.
The calls to each one of those methods return strings independently. There is no shared place for them to store their rendered HTML.
In situations like this, I usually look at the Rails source code to see how the framework handles it and whether there is a simple way to replicate that approach, instead of adding more dependencies to the project.
In a Rails view, every
<%= ... %>tag appends its result to@output_buffer, anActionView::OutputBuffermaintained during rendering.
capture { ... }works by swapping the buffer: it saves the current@output_buffer, installs a fresh one, yields the block (so any<%= ... %>inside writes into the new buffer), then restores the original and returns what was captured.Block-form helpers like
content_tag(:div) { ... }use capture internally to collect their block's output before wrapping it in markup.But this only works when the caller is an ERB template rendered by
ActionView. In plain Sinatra there is no@output_bufferand nocapture, so the nested DSL above has nowhere to collect its children's output.
Next, I shared what I wanted to achieve with Claude Code in plan mode, and after a few iterations, I got the AI to propose an implementation that seemed simple enough.
The implementation, slightly simplified, is as follows:
module Components
module Table
def table(&block)
content = capture_table_content(&block)
Components.load_template("table/_table.erb", binding)
end
def table_head(&block)
content = capture_table_content(&block)
result = Components.load_template("table/_head.erb", binding)
append_to_table_buffer(result)
end
def table_body(&block)
content = capture_table_content(&block)
result = Components.load_template("table/_body.erb", binding)
append_to_table_buffer(result)
end
def table_row(&block)
content = capture_table_content(&block)
result = Components.load_template("table/_row.erb", binding)
append_to_table_buffer(result)
end
def table_header_cell(content, **sortable_params)
result = Components.load_template("table/_header_cell.erb", binding)
append_to_table_buffer(result)
end
def table_cell(content)
result = Components.load_template("table/_cell.erb", binding)
append_to_table_buffer(result)
end
# other helpers omitted
private
def table_buffer_stack
Thread.current[:table_buffer_stack] ||= []
end
def capture_table_content(&block)
table_buffer_stack.push([])
block.call
table_buffer_stack.pop.join
end
def append_to_table_buffer(html)
table_buffer_stack.last << html if table_buffer_stack.any?
html
end
end
end
Two ideas are doing the work here:
A stack of arrays instead of a single buffer, so nested blocks do not stomp on each other:
table do # buffer_stack = [[]] table_body do # buffer_stack = [[], []] table_row do # buffer_stack = [[], [], []] table_cell "a" # buffer_stack = [[], [], ["<td>a</td>"]] table_cell "b" # buffer_stack = [[], [], ["<td>a</td>", "<td>b</td>"]] end # buffer_stack = [[], ["<tr><td>a</td><td>b</td></tr>"]] end # buffer_stack = [["<tbody>…</tbody>"]] end # returns "<table><tbody>…</tbody></table>"table_bodypushes a fresh array; its innertable_rowpushes another on top; when eachpops, the content of the inner stack merges back into the outer one viaappend_to_table_buffer. Only the outermosttablereturns anything, the joined string after the last pop. Every other call's return value is thrown away on purpose: ifcapture_table_contenttrusted the block's return value, Ruby's last-expression rule would hand back only the lasttable_cell's HTML and every earlier cell would vanish.Nesting a table inside another table works for the same reason, an inner
table dojust pushes one more array onto the stack, fills it, pops it, and deposits the whole inner<table>…</table>string onto its parent's array like any other child's output.Thread-local storage, because Sinatra on a threaded server, like Puma, handles requests in parallel:
Thread.current[:table_buffer_stack]If
table_buffer_stackwere a class-level@@buffer_stackinstead, two requests enteringtable doat the same moment would push onto the same array; each one'stable_cellcould append into the other's array, mixing rows across responses.Thread.currentgives each thread its own stack, so the two requests can not see each other's state.
All that works perfectly for the table component, but it does not for the next component I needed to create.
The second block DSL component: modal
The goal was to create a modal component that would allow consumers to write something like the following inside a single <%= %> tag:
modal do
modal_header do
"Confirm status change"
end
modal_body do
<<~CONTENT
<p>Do you confirm the change of status?</p>
<p><span>OLD_STATUS</span> -> <span>NEW_STATUS</span></p>
CONTENT
end
modal_footer do
modal_cancel
modal_action "Confirm", htmx: { ... }
end
end
The implementation is basically the same as for the table component:
module Components
module Modal
def modal(&block)
content = capture_modal_content(&block)
Components.load_template("modal/_modal.erb", binding)
end
# other helpers omitted
private
def modal_buffer_stack
Thread.current[:modal_buffer_stack] ||= []
end
def capture_modal_content(&block)
modal_buffer_stack.push([])
result = block.call
buffer = modal_buffer_stack.pop
buffer.empty? ? result.to_s : buffer.join
end
end
end
There are only two differences:
The thread-local key used to store the component's content in
Thread.currentis different,:modal_buffer_stack.There is an important change in the method that captures the component's content:
buffer.empty? ? result.to_s : buffer.joinIt falls back to the block's return value if the block did not call any DSL child.
For instance:
modal_header { "Confirm status change" }The block returns a bare string, so nothing is pushed to the buffer. Without the fallback, those blocks would render empty.
Table got away with
pop.joinbecause everytableblock is expected to contain child DSL calls (table_row,table_cell, etc.).
That code works perfectly for both components, but there is too much duplicated plumbing behind both components.
Extracting the common logic
The next step was to extract the common logic for all components that implement a block DSL into a module so it can be reused:
module ContentBuffer
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def content_buffer_key(key)
define_method(:_content_buffer_key) { key }
private :_content_buffer_key
end
end
private
def buffer_stack
Thread.current[_content_buffer_key] ||= []
end
def capture_content(&block)
buffer_stack.push([])
result = block.call
buffer = buffer_stack.pop
buffer.empty? ? result.to_s : buffer.join
end
def append_to_buffer(html)
buffer_stack.last << html if buffer_stack.any?
html
end
end
A class-level setter handles the thread-local key name in the clients of the new module:
module Components
module Table
include ContentBuffer
content_buffer_key :table_buffer_stack
def table(&block)
content = capture_content(&block)
Components.load_template("table/_table.erb", binding)
end
def table_head(&block)
content = capture_content(&block)
result = Components.load_template("table/_head.erb", binding)
append_to_buffer(result)
end
# other helpers omitted
end
end
module Components
module Modal
include ContentBuffer
content_buffer_key :modal_buffer_stack
def modal(&block)
content = capture_content(&block)
Components.load_template("modal/_modal.erb", binding)
end
def modal_header(&block)
content = capture_content(&block)
result = Components.load_template("modal/_header.erb", binding)
append_to_buffer(result)
end
# other helpers omitted
end
end
Besides, extracting that logic into a module makes testing easier.
To do this, we first create a couple of test components:
module TestComponents
module Container
include ContentBuffer
content_buffer_key :container_buffer_stack
def container(&block)
capture_content(&block)
end
def item(content)
result = "<item>#{content}</item>"
append_to_buffer(result)
end
def group(&block)
content = capture_content(&block)
result = "<group>#{content}</group>"
append_to_buffer(result)
end
end
module Widget
include ContentBuffer
content_buffer_key :widget_buffer_stack
def widget(&block)
capture_content(&block)
end
def widget_part(content)
result = "<part>#{content}</part>"
append_to_buffer(result)
end
end
end
Then we add the following test examples:
RSpec.describe ContentBuffer do
let(:component) do
Class.new do
include TestComponents::Container
end.new
end
after do
Thread.current[:container_buffer_stack] = nil
end
describe "block return value fallback" do
it "returns the block return value when no items are appended" do
result = component.container { "plain text" }
expect(result).to eq("plain text")
end
it "converts non-string return values to string" do
result = component.container { 42 }
expect(result).to eq("42")
end
end
describe "buffered content" do
it "joins appended items" do
result = component.container do
component.item("one")
component.item("two")
component.item("three")
end
expect(result).to eq("<item>one</item><item>two</item><item>three</item>")
end
it "ignores the block return value when items are appended" do
result = component.container do
component.item("buffered")
"ignored"
end
expect(result).to eq("<item>buffered</item>")
end
end
describe "nested captures" do
it "inner group does not leak into outer container" do
result = component.container do
component.item("before")
component.group do
component.item("nested")
end
component.item("after")
end
expect(result).to eq("<item>before</item><group><item>nested</item></group><item>after</item>")
end
end
describe "isolation between different buffer keys" do
let(:other_component) do
Class.new do
include TestComponents::Widget
end.new
end
after do
Thread.current[:widget_buffer_stack] = nil
end
it "two components with different keys do not interfere" do
widget_result = nil
container_result = component.container do
component.item("a")
widget_result = other_component.widget do
other_component.widget_part("b")
end
component.item("c")
end
expect(container_result).to eq("<item>a</item><item>c</item>")
expect(widget_result).to eq("<part>b</part>")
end
end
end
The third block DSL component: alert
The goal was to create an alert component that would allow consumers to write something like the following inside a single <%= %> tag:
alert(variant: :success) do
alert_icon
alert_title { "Payment processed successfully" }
alert_description { "The transaction has been completed." }
alert_actions do
alert_action("View details", variant: :primary, htmx: { ... })
alert_action("Dismiss", variant: :secondary, htmx: { ... })
end
end
The variant should set the background color and the border, as well as the icon to display and its color, if alert_icon is included in the alert.
This poses a problem because alert_icon, if it receives no arguments, has to render the default icon according to the variant defined in the parent. However, the buffer does not allow a child to access data from the parent.
Furthermore, the alert's icon and actions are not rendered in the same block as the title and description. The template does not receive a single flat content string to print; instead, it needs the icon and actions separately, as independent variables, to place them in their respective spots. The buffer at this point only returns a plain string with everything concatenated and is incapable of separating that out.
The code that solves these problems is the following:
def context_stack
Thread.current[:"#{_content_buffer_key}_context"] ||= []
end
def set_context(metadata)
context_stack.push(metadata)
end
def reset_context
context_stack.pop
end
def current_context
context_stack.last || {}
end
Therefore, the ContentBuffer module now has two main parts:
- Buffer: a linear, append-only stream of HTML fragments from children.
- Context: a shared hash the parent seeds, that children can read from and write into.
The context key is derived from the same
_content_buffer_keythe buffer uses, with a_contextsuffix appended. This way, each component that includesContentBufferends up with its own isolated buffer stack and context stack.
The context uses a stack, rather than a single hash, for the same reason the buffer does: a component nested inside another cannot overwrite the parent's context.
The implementation of the alert component, slightly simplified, is as follows:
module Components
module Alert
include ContentBuffer
content_buffer_key :alert_buffer_stack
VARIANTS = {
success: {
...
},
error: {
...
},
}.freeze
def alert(variant:, &block)
set_context(variant:, icon: nil, actions: nil)
content = capture_content(&block)
alert_icon = current_context[:icon]
alert_actions = current_context[:actions]
reset_context
Components.load_template("alert/_alert.erb", binding)
end
def alert_icon(icon_name = nil)
variant = current_context[:variant]
current_context[:icon] = icon(icon_name || VARIANTS.dig(variant, :icon_name))
""
end
def alert_title(&block)
content = capture_content(&block)
result = Components.load_template("alert/_title.erb", binding)
append_to_buffer(result)
end
def alert_description(&block)
content = capture_content(&block)
result = Components.load_template("alert/_description.erb", binding)
append_to_buffer(result)
end
def alert_actions(&block)
content = capture_content(&block)
current_context[:actions] = Components.load_template("alert/_actions.erb", binding)
""
end
def alert_action(text, **options)
result = button(text, **options)
append_to_buffer(result)
end
end
end
Two points to highlight here:
alert_iconandalert_actionsreturn an empty string. Instead of writing to the buffer, they store their rendered HTML in a named slot in the context (:iconand:actionsrespectively). The parentalertmethod reads those slots into its own local variables after running the block, and passes them to the template as independent variables.alert_titleandalert_descriptionwrite to the buffer exactly like table and modal children do, so they end up in the concatenated content.
A single alert uses both mechanisms: title and description go into the concatenated content, icon and actions into named slots.
Next, we create a couple more test components:
module TestComponents
# other code omitted
module Panel
include ContentBuffer
content_buffer_key :panel_buffer_stack
def panel(variant:, &block)
set_context(variant:, icon: nil)
content = capture_content(&block)
icon = current_context[:icon]
reset_context
"<panel variant='#{variant}' icon='#{icon || "none"}'>#{content}</panel>"
end
def panel_icon
variant = current_context[:variant]
current_context[:icon] = "icon-for-#{variant}"
""
end
def panel_body(content)
append_to_buffer("<body>#{content}</body>")
end
def read_current_context
current_context
end
end
module Banner
include ContentBuffer
content_buffer_key :banner_buffer_stack
def banner(variant:, &block)
set_context(variant:)
content = capture_content(&block)
reset_context
"<banner variant='#{variant}'>#{content}</banner>"
end
def read_current_context
current_context
end
end
end
And we add the following test examples:
RSpec.describe ContentBuffer do
# other code omitted
describe "context stack" do
let(:panel_component) do
Class.new do
include TestComponents::Panel
end.new
end
let(:banner_component) do
Class.new do
include TestComponents::Banner
end.new
end
after do
Thread.current[:panel_buffer_stack] = nil
Thread.current[:panel_buffer_stack_context] = nil
Thread.current[:banner_buffer_stack] = nil
Thread.current[:banner_buffer_stack_context] = nil
end
it "returns empty hash when no context is set" do
expect(panel_component.read_current_context).to eq({})
end
it "sets and clears context" do
inside_context = nil
panel_component.panel(variant: :success) do
inside_context = panel_component.read_current_context
end
expect(inside_context).to eq(variant: :success, icon: nil)
expect(panel_component.read_current_context).to eq({})
end
it "nests contexts" do
nested_context = nil
outer_context_after = nil
panel_component.panel(variant: :info) do
panel_component.panel(variant: :error) do
nested_context = panel_component.read_current_context
end
outer_context_after = panel_component.read_current_context
end
expect(nested_context).to eq(variant: :error, icon: nil)
expect(outer_context_after).to eq(variant: :info, icon: nil)
end
it "isolates context between different buffer keys" do
panel_context = nil
banner_context = nil
panel_component.panel(variant: :success) do
banner_component.banner(variant: :error) do
panel_context = panel_component.read_current_context
banner_context = banner_component.read_current_context
end
end
expect(panel_context).to eq(variant: :success, icon: nil)
expect(banner_context).to eq(variant: :error)
end
end
end
The full ContentBuffer module
Putting it all together, the final ContentBuffer module looks like this:
module ContentBuffer
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def content_buffer_key(key)
define_method(:_content_buffer_key) { key }
private :_content_buffer_key
end
end
private
def buffer_stack
Thread.current[_content_buffer_key] ||= []
end
def capture_content(&block)
buffer_stack.push([])
result = block.call
buffer = buffer_stack.pop
buffer.empty? ? result.to_s : buffer.join
end
def append_to_buffer(html)
buffer_stack.last << html if buffer_stack.any?
html
end
def context_stack
Thread.current[:"#{_content_buffer_key}_context"] ||= []
end
def set_context(metadata)
context_stack.push(metadata)
end
def reset_context
context_stack.pop
end
def current_context
context_stack.last || {}
end
end
Possible improvements
The module is simple and covers every block DSL component I have needed so far, but there are still a few rough edges worth calling out before wrapping up, although it is not meant to be a thorough list:
Exception safety:
capture_contentpushes and pops the buffer stack by hand, andalertdoes the same withset_contextandreset_context. If the block raises in between, the stack keeps the orphaned frame. Abegin/ensurearound each pair fixes both.Explicit buffer-vs-context contract: Writing to the buffer means returning a string; writing to a slot means returning
""and assigning into the context by hand. A helper likewith_slot(slot_name) { ... }would make the slot write the block's explicit purpose instead of an empty-string side effect.Typed context slots: The slots
:icon,:actions, and:variantare untyped hash keys, so a typo silently returnsnil. Acontext_slotsclass macro, alongsidecontent_buffer_key, would declare each component's slots and catch typos.Rename the module:
ContentBufferfit when there was only a buffer. With the context stack in place, it should have a name that fits better.Extend module API: add a method to reset the values stored in the tests, instead of assigning
nilin anafterblock.
Conclusion
Building your own component system from scratch forces you to face problems that frameworks like Rails solve for you. Once you solve them yourself, even in a small way, they stop feeling like magic.
The ContentBuffer module is not trying to be a general-purpose abstraction. It only has to work for the
components that use it today, and that is why it stays small.
In exchange, what you get is Rails-like ergonomics on top of ERB in Sinatra, with no extra dependencies. There is still room for improvement, but the core is simple and easy to test.
If you have an alternative approach to this problem, feel free to share it with me.
Thank you for reading, and see you in the next one!



