Views, Partials & Helpers: One Renderer, One Address
A Foundations tutorial on Rails views — ERB, partials, dom_id, layouts, and helpers — derived from first principles and grounded in how Campfire turns a message into HTML exactly once and addresses it the same way on every path. Builds the view-side mechanics that P4 (Convention Is Leverage) and P5 (One Renderer) later turn into a worldview.
You've persisted a message. Now you have to show it — and not in one place. The same message has to appear when someone first opens the room, again the instant it's broadcast live to everyone else, again after a sleeping laptop wakes up and catches up, and again — as an editor — the moment its author clicks the pencil. Four moments, one message.
Here's the version you'd vibe-code first, because it's the honest first draft. You write the message markup in the room's show view. Then the live-update path needs the same markup, so you write it again in the broadcast template. The catch-up refresh needs it, so a third copy. And every place that targets a message — append here, replace there — needs the element's DOM id, so you type one:
<%# rooms/show.html.erb — first draft %>
<div id="room_<%= @room.id %>_messages">
<% @messages.each do |message| %>
<div id="msg-<%= message.id %>" class="message">
<strong><%= message.creator.name %></strong>
<%= message.body %>
</div>
<% end %>
</div>
<%# create.turbo_stream.erb — the live path, second draft of the SAME markup %>
<%= turbo_stream.append "room_#{@message.room.id}_messages" do %>
<div id="message_<%= @message.id %>" class="message"> <%# ...wait, "msg-" or "message_"? %>
<strong><%= @message.creator.name %></strong>
<%= @message.body %>
<% end %>
<% end %>
Look at what just happened. The container is room_5_messages in the view and room_#{id}_messages in the stream — fine, until you rename it in one file and forget the other. Each row is msg-#{id} in one place and message_#{id} in another — they already disagree, and nothing errors. And the markup itself is now written twice; the day you add a boost badge to the page version and forget the broadcast version, half your screens show it and half don't. There is no exception thrown.
Messages just quietly stop landing in the right place.
This whole class of bug has one root cause: two things you have to keep in sync by hand. Two id strings. Two copies of markup. The fix is not discipline — it's to make both of them computed in one place instead of typed in two.
That's what the view layer is for, and it's the single highest-conviction idea in the Campfire codebase.
Let's build it from the mechanics up.
The mechanics: ERB, partials, and the loop you stop writing
ERB is just Ruby interleaved with markup — <%= %> prints a value, <% %> runs code without printing. A partial is a template fragment in its own file, named with a leading underscore (_message.html.erb), that you can render anywhere. So far, nothing surprising.
The first real lever is what happens when you render a collection. The naive loop:
<% @messages.each do |message| %>
<%= render "messages/message", message: message %>
<% end %>
works, but you wrote a loop, you named the local message: by hand each iteration, and there's no shared caching. Rails gives you the convention form:
<%= render partial: "messages/message", collection: @messages %>
This renders the _message partial once per element, automatically assigning each element to a local named after the partial (message). One line replaces the loop. And because it's one render call over a known collection, you can add cached: true and Rails turns N separate fragment lookups into a single batched read_multi — the deep mechanics of that live in see F6: Caching; here just notice that the collection render is the seam that makes batched caching possible at all.
"A partial per row sounds like a lot of render calls — isn't a loop with the markup inline faster?" The render count is identical; you've only moved the markup into one named file. What you've bought is that the row markup now exists exactly once. Every other path that needs to draw a message will point at this same file instead of re-typing it. The duplication you were about to commit is the expensive thing, not the partial boundary.
The mechanics: dom_id is the address
Now the id problem. The naive instinct is to build the id string yourself — "message_#{message.id}". The convention is to ask the framework: dom_id(record) returns a stable, collision-free DOM id derived from the record ("message_47"), and dom_id(record, :prefix) prefixes it ("edit_message_47").
The point is not that it's shorter. The point is where the knowledge lives. When the view writes id: dom_id(message) and a later operation targets dom_id(message), neither side typed a string — both asked the same function the same question about the same record. They cannot disagree, because there is no second copy to drift from the first. Rename the convention and both sides change together, because there's only one place that computes it. dom_id is the address every render path derives instead of hand-typing.
Fizzy does the same in an unrelated product — cards_helper.rb:2 computes each card's id as dom_id(card, :article) and never a literal string, then reuses that same value for the CSS view-transition-name (cards_helper.rb:14) — so even the animation hook can't drift from the element's address. Deriving the id instead of typing it is the 37signals way, not a Campfire habit.
This is the view-layer face of a bigger idea — that convention turns "two things I sync by hand" into "one thing computed once" — whose full worldview is see P4: Convention Is Leverage. Here we only need the mechanic: the id is computed, never copied.
The 37signals way: one partial, one address, every path
Open app/helpers/messages_helper.rb. The container and the row both get their id the same way — never a literal string (messages_helper.rb:16,28):
def messages_tag(room, &)
tag.div id: dom_id(room, :messages), class: "messages", data: {
controller: "maintain-scroll refresh-room",
action: [ maintain_scroll_actions, refresh_room_actions ].join(" "),
messages_target: "messages",
refresh_room_loaded_at_value: room.updated_at.to_fs(:epoch),
refresh_room_url_value: room_refresh_url(room)
}, &
end
def message_tag(message, &)
message_timestamp_milliseconds = message.created_at.to_fs(:epoch)
tag.div id: dom_id(message),
class: "message #{"message--emoji" if message.plain_text_body.all_emoji?}",
data: {
controller: "reply",
user_id: message.creator_id,
message_id: message.id,
message_timestamp: message_timestamp_milliseconds,
message_updated_at: message.updated_at.to_fs(:epoch),
sort_value: message_timestamp_milliseconds,
messages_target: "message",
search_results_target: "message",
refresh_room_target: "message",
reply_composer_outlet: "#composer"
}, &
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
Rails.logger.error "Exception while rendering message #{message.class.name}##{message.id}, failed with: #{e.class} `#{e.message}`"
render "messages/unrenderable"
end
dom_id(room, :messages) is the container's address; dom_id(message) is each row's address. Now watch one partial paint two different paths. The initial page load renders the collection (rooms/show.html.erb:17):
<%= render partial: "messages/message", collection: @messages, cached: true %>
And the standalone message index renders the identical line (index.html.erb:1):
<%= render partial: "messages/message", collection: @messages, cached: true %>
Same partial, same cached: true, two entry points. There is no index-specific message markup and no show-specific message markup — there is messages/_message, and that is the only thing that knows what a message looks like. The live WebSocket append and the wake-from-sleep refresh render this same partial too; that cross-transport half is see F4: Turbo (Hotwire)'s to detail, and the full one-renderer worldview is see P5: One Renderer. The view-mechanics fact is the one that matters here: one partial, every path.
"How does the partial paint a row AND the container without two different templates?" The container is a helper (
messages_tag, which yields), the row is the partial (_message). The partial never re-decides the container id and the helper never re-decides the row markup — each owns exactly one thing, and both derive their id fromdom_id. There's no third place where they could disagree.
The contrast: the naive version up top had the row markup in show.html.erb and again in create.turbo_stream.erb, with the container id typed as "room_#{id}_messages" in one and "room_#{@message.room.id}_messages" in the other. The day a boost badge lands in one copy and not the other, or a rename touches one id string and not its twin, the app breaks silently. Campfire cannot grow that bug: there is one partial and one dom_id, so there is nothing to keep in sync.
The partial guards its own twin
There is one honest exception in Campfire, and it's worth seeing because of how it's handled. The optimistic placeholder the sender draws before the round-trip is a static HTML template (_template.html.erb) — it can't run ERB on a record that doesn't exist on the server yet, so its markup is necessarily a second copy of the row. (Why the placeholder exists at all — the optimistic-id handshake — is see F4: Turbo (Hotwire).) The two files that must stay in sync announce it to each other in their first line (_message.html.erb:1):
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
When you genuinely can't collapse two copies into one, the next best thing is to make the coupling loud — a comment at the top of each file pointing at its twin. It's the same instinct as dom_id, applied where the machine can't do it for you: a human will. Notice too that _template.html.erb:3 hard-codes the id as id="message_$clientMessageId$" — the client's chosen id, the same shape dom_id will produce server-side. Even the placeholder speaks the address convention.
Edit-in-place: three files agree, the controller does nothing
Here's the most striking payoff of "the id is the contract." A message can turn into an editor in place, and the controller action that does it is empty.
The row wraps its body in a Turbo Frame addressed by dom_id(message, :edit) (_message.html.erb:11):
<turbo-frame id="<%= dom_id(message, :edit) %>">
The Edit link targets that exact frame (_actions.html.erb:51-52):
<%= link_to edit_room_message_path(message.room, message),
data: { turbo_frame: dom_id(message, :edit) }, ... %>
And the edit response wraps its form in a frame with the same id (edit.html.erb:1):
<turbo-frame id="<%= dom_id(@message, :edit) %>">
Three files, one address, all derived from dom_id(message, :edit) — never typed. When the link loads the edit response, Turbo sees a frame whose id matches a frame already on the page and swaps that region in place. No JavaScript you wrote, no controller logic. Which is why the controller's edit action is, in full (messages_controller.rb:33-34):
def edit
end
Empty.
The convention does the entire swap.
def edit; end is not laziness — it's the visible proof that the id agreement carried all the weight. The deep render-side worldview (edit-in-place, replace-for-spectators) belongs to see P5: One Renderer; the view fact is that matching dom_ids across three files is what makes the controller able to do nothing.
"If
editis empty, what renders the form?" The convention: an action namededitwith no explicitrenderrendersedit.html.erb. That template's outermost element is a frame whose id matches the row's frame, so Turbo targets it. The action has nothing to do — the template's frame id already said where the result goes.
Layouts and a CSS state driven by a local
Two smaller mechanics round out the view layer. A layout wraps every rendered view; yield marks where the view's content drops in, and a view can inject into a named region with content_for :region. Campfire uses this to place the sidebar from inside the room view (rooms/show.html.erb:10):
<% content_for :sidebar, sidebar_turbo_frame_tag(src: user_sidebar_path) %>
The view doesn't render the sidebar where this line sits — it stashes it into the :sidebar region the layout will yield elsewhere. The helper behind it composes a lazy, session-persistent frame (users/sidebar_helper.rb:2-9) — that turbo_permanent frame is see F4: Turbo (Hotwire)'s topic; here it's just an example of a helper returning a tag that a layout region renders.
The second: keep branching out of ERB by driving CSS from data. The sidebar's unread state is not an if in the template — it's a boolean class toggled by a passed-in local (users/sidebars/rooms/_shared.html.erb:3):
class: [ "align-center gap room btn txt-nowrap", "unread": local_assigns[:unread] ]
The array form of class: includes "unread" only when local_assigns[:unread] is truthy. Whether the room is unread is decided by whoever renders the partial and passed as a local; the partial just reflects it. No <% if unread %>...<% end %> wrapping the markup, no logic to read twice.
Helpers: giving the view a vocabulary
The last mechanic: a helper is a plain Ruby method available in views, and its job is to let the ERB read in the domain's words instead of framework primitives. messages_helper.rb is exactly this — message_tag, messages_tag, message_timestamp, message_presentation — each wrapping a tag.div (and its dom_id call, its data attributes) so the template can say messages_tag(@room) do ... end instead of inlining a tag.div id: dom_id(room, :messages), data: { ...eight attributes... }. The logic — the id computation, the data wiring — lives in one method; the view reads like a sentence.
This isn't a Campfire quirk: Fizzy reaches for the identical move in cards_helper.rb:2-19, where card_article_tag wraps a tag.article together with its dom_id(card, :article) id, its state classes (golden-effect, card--postponed, card--active), and its data attributes — so a board template says card_article_tag(card) do ... end instead of re-typing all of that. Two unrelated 37signals products, same instinct: the markup that's about to be shared gets a named home in a helper.
This is half of a larger pattern. The model-side mechanics of organizing behavior (the include line — the include line IS the spec — included do) live in see F7: Concerns as a Mechanism; helpers are the view-side version of the same instinct — a named home for vocabulary so logic stays out of the markup.
"Why not just put a three-line
tag.divstraight in the ERB?" Because the moment two templates need that same div — the page and the broadcast — you'd copy it, and you're back to the two-copies bug. A helper is where the markup that's about to be shared goes to live exactly once, the same reason_messageis a partial.
Key Takeaways — Patterns to Steal
- When a view needs to draw every element of a collection, reach for
render partial: "messages/message", collection: @messagesrather than writing<% @messages.each do |message| %>and naming themessage:local by hand each pass — the convention form renders one partial per element and names the local for you. The hidden payoff is that this single render call over a known collection is the only placecached: truecan hook in to collapse N fragment lookups into oneread_multi; the loop you'd have written first forecloses that. Campfire'srooms/show.html.erb:17is exactly this one line (withcached: truealready attached). - Never type a DOM id string like
"message_#{message.id}"— askdom_id(record)(ordom_id(record, :prefix)) and let the framework derive it from the record. The point isn't brevity; it's that the writer and every later operation that targets the element both ask the same function the same question, so there is no second copy to drift into a silent"msg-#{id}"-vs-"message_#{id}"mismatch that errors nowhere. You can see it inmessages_helper.rb:16,28, where the container isdom_id(room, :messages)and the row isdom_id(message), both computed, never literal. - Resist the urge to keep one copy of the row markup in the
showview and another in the broadcast template — writemessages/_messageonce and point every path at it. That single partial is rendered by the page load, the standalone index, the live append, and the wake-from-sleep refresh, so the day you add a boost badge there's no second template to forget and no half-your-screens-have-it bug. Campfire renders the identicalrender partial: "messages/message", collection: @messages, cached: trueline from bothrooms/show.html.erb:17andindex.html.erb:1. - When two templates genuinely can't be collapsed into one — the optimistic placeholder is static HTML that can't run ERB on a record the server doesn't have yet — don't just quietly leave two copies and hope you remember; make the coupling loud with a comment at the top of each file naming its twin. It's the same instinct as
dom_idapplied where the machine can't enforce it, so a human will. Campfire's first line of_message.html.erb:1reads<%# Be sure to check/update messages/_template.html.erb when changing this file %>, and the placeholder even hard-codes the samemessage_$clientMessageId$id shapedom_idproduces server-side (_template.html.erb:3). - When you want a row to turn into an editor in place, don't reach for a full-page reload or hand-wire the swap — make the row's frame, the Edit link's target, and the edit response's frame all carry the same
dom_id(message, :edit), and Turbo swaps the matching region for you. The id agreement across the three files is what carries the entire swap, which is whymessages_controller.rb:33-34is literallydef edit; end— empty, because an action namededitwith no explicitrenderrendersedit.html.erb, whose outermost frame id already says where the result lands. The three addresses live at_message.html.erb:11,_actions.html.erb:51-52, andedit.html.erb:1. - When a view computes a piece of UI that belongs somewhere else on the page, don't contort the template's structure to render it in physical position — stash it into a named layout region with
content_for :regionand let the layoutyieldit where it belongs. Campfire places the sidebar from inside the room view withcontent_for :sidebar, sidebar_turbo_frame_tag(...)atrooms/show.html.erb:10, so the line that decides the sidebar isn't the line that paints it. - When a
tag.divwith itsdom_idcall and a fistful of data attributes is about to appear in two templates — the page and the broadcast — don't inline it in both; put it in a helper so the markup lives exactly once and the ERB reads in the domain's words. A template sayingmessages_tag(@room) do ... endkeeps the id computation and data wiring in one method instead of copied into every path, the same two-copies bug_messagebeing a partial avoids, one altitude up. Campfire'smessages_helper.rb:15-47is precisely this:messages_tagandmessage_tageach wrapping atag.div, itsdom_id, and its data attributes.