Upgrade to Pro — share decks privately, control downloads, hide ads and more …

HTML-Aware ERB: The Path to Reactive Rendering ...

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.

HTML-Aware ERB: The Path to Reactive Rendering @ RubyKaigi 2026, Hakodate, Japan

ERB templates in Ruby are traditionally rendered by engines that treat templates as string generators, which makes it difficult to reason about HTML structure, state changes, or incremental updates. These limitations become especially visible in modern HTML-over-the-wire workflows.

In this talk, we explore what it would take to build a reactive ERB rendering engine. We begin by revisiting how existing ERB engines compile templates into Ruby code and look at how recent advances in HTML-aware parsing and tooling make new approaches possible.

Using Herb::Engine as a concrete example, we examine how structural understanding enables state-aware rendering, diff-based updates, and a more responsive development experience, including instant updates without full page reloads and rich in-browser developer tools. Such as template outlines, partial and ERB structure views, jump-to-source navigation, and many other innovations.

https://rubykaigi.org/2026/presentations/marcoroth.html

Avatar for Marco Roth

Marco Roth

April 24, 2026

More Decks by Marco Roth

Other Decks in Programming

Transcript

  1. Marco Roth !  @marcoroth_  @[email protected] 🌐 marcoroth.dev 

    @marcoroth Full-Stack Developer & Open Source Contributor  @marcoroth.dev
  2. ERB

  3. <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div> <div> <span><%=

    @user.firstname %></span> <b><%= @user.lastname %></b> </div>
  4. Major Ruby DX Events Since Ruby 3.0 2021 2022 2023

    2024 2026 2025/11/06 Ruby 3.0 (release) 2025 repl_type_completor (release) Tomoya Ishida RubyWorld Conference 2025 Steep (v1.0 release) Soutaro Matsumoto debug.gem (release)
 Koichi Sasada RDoc modernization IRB & Reline transformation Tomoya Ishida Mari Imaizumi
 Hitoshi Hasumi Herb (release) Marco Roth Prism (release) Kevin Newton Ruby LSP (release) Vini Stock RBS in Sorbet Alexandre Terrasa
 Alexander Momchilov rbs-inline (release) Soutaro Matsumoto https://github.com/st0012/slides/tree/main/2025-11-06-ruby-world-conference
  5. Importance of Shared Infrastructure RubyWorld Conference 2025 2025/11/06 Prism Herb

    repl_type_completor Ruby LSP Sorbet RuboCop RDoc (Experimental/WIP) Steep RBS https://github.com/st0012/slides/tree/main/2025-11-06-ruby-world-conference
  6. Missing <% end %>? → Herb + Prism catch it

    at parse time Invalid Ruby syntax? → Prism catches it at parse time Unclosed HTML tag? → Herb catches it at parse time
  7. If your editor tells you there is an error, you

    don't have to run the code to find out
  8. _buf = ::String.new _buf << '<h1>Hello, ' _buf << ::Erubi.h(name)

    _buf << '!</h1' _buf.to_s (simplified+formatted)
  9. eval(" _buf = ::String.new _buf << '<h1>Hello, ' _buf <<

    ::Erubi.h(name) _buf << '!</h1' _buf.to_s ")
  10. eval(" _buf = ::String.new _buf << '<h1>Hello, ' _buf <<

    ::Erubi.h(name) _buf << '!</h1' _buf.to_s ") => "<h1>Hello RubyKaigi!</h1"
  11. @ DocumentNode └── children: (1 item) └── @ HTMLElementNode ├──

    open_tag: │ └── @ HTMLOpenTagNode │ ├── tag_opening: "<" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── tag_name: "h1" ├── body: (3 items) │ ├── @ HTMLTextNode │ │ └── content: "Hello, " │ │ │ ├── @ ERBContentNode │ │ ├── tag_opening: "<%=" │ │ ├── content: " name " │ │ └── tag_closing: "%>" │ │ │ └── @ HTMLTextNode │ └── content: "!" │ └── close_tag: └── @ HTMLCloseTagNode ├── tag_opening: "</" ├── tag_name: "h1" └── tag_closing: ">"
  12. @ DocumentNode └── children: (1 item) └── @ HTMLElementNode ├──

    open_tag: │ └── @ HTMLOpenTagNode (<h1>) │ ├── body: (3 items) │ ├── @ HTMLTextNode ("Hello, ") │ ├── @ ERBContentNode (<%= name %>) │ └── @ HTMLTextNode ("!") │ └── close_tag: └── @ HTMLCloseTagNode (</h1>)
  13. class Compiler < Herb::Visitor def visit_document_node(node) def visit_html_element_node(node) def visit_html_open_tag_node(node)

    def visit_html_close_tag_node(node) def visit_html_text_node(node) def visit_erb_content_node(node) end
  14. @ ERBContentNode ├── tag_opening: "<%=" ├── content: " name "

    └── tag_closing: "%>" def visit_erb_content_node(node) if node.tag_opening == "<%=" add_expression(node.content) else add_code(node.content) end end
  15. def add_text(text) @tokens << [:text, text] end def add_expression(code) @tokens

    << [:expr, code] end def add_code(code) @tokens << [:code, code] end
  16. @tokens = [ ["<", :text], ["h1", :text], [">", :text], ["Hello,

    ", :text], ["name", :expr], ["!", :text], ["</", :text], ["h1", :text], [">", :text], ]
  17. @tokens = [ ["<", :text], ["h1", :text], [">", :text], ["Hello,

    ", :text], ["name", :expr], ["!", :text], ["</", :text], ["h1", :text], [">", :text], ]
  18. @tokens = [ ["<h1>Hello, ", :text], ["name", :expr], ["!</h1>", :text],

    ] add_text("<h1>Hello, ") add_expression("name") add_text("!</h1")
  19. _buf = ::String.new _buf << '<h1>Hello, ' _buf << ::Erubi.h(name)

    _buf << '!</h1>' _buf.to_s (simplified+formatted) _buf = ::String.new _buf << '<h1>Hello, ' _buf << ::Herb.h(name) _buf << '!</h1>' _buf.to_s Erubi::Engine Herb::Engine
  20. → Reactivity → Optimistic UI updates → Offline functionality →

    Lazy loading driven by the template → Instant interactions without full reloads → Beautiful, modern UX → Component-driven deferred rendering
  21. <ul> <% @items.each do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered
  22. <ul> <% @items.each do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed
  23. <ul> <% @items.each do |item| %> <li><%= item %></li> <%

    end %> </ul> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view @items = [1, 2, 3, 4]
  24. <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View

    Log Initial view rendered @items state changed re-rendering view tracing dependencies <ul> <% @items.each do |item| %> <li><%= item %></li> <% end %> </ul> @items = [1, 2, 3, 4]
  25. <ul> <% @items.each do |item| %> <li><%= item %></li> <%

    end %> </ul> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view tracing dependencies render delta for item `4` @items = [1, 2, 3, 4]
  26. <ul> <% @items.each do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view tracing dependencies render delta for item `4` <li>4</li>
  27. <ul> <% @items.each do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view tracing dependencies render delta for item `4` <li>4</li>
  28. <ul> <% @items.each do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view tracing dependencies render delta for item `4` apply delta to view <li>4</li>
  29. <ul> <% @items.each do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> <li>4</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view tracing dependencies render delta for item `4` apply delta to view
  30. <div class="container py-8"> <%= render partial: "profiles/header", locals: { user:

    @user } %> <%= render partial: "profiles/topics", locals: { topics: @topics } %> <%= render partial: "profiles/navigation" %> <div class="mt-6"> <% if @talks.any? %> <%= render partial: "profiles/talks", locals: { talks: @talks } %> <% else %> <%= render partial: "profiles/events", locals: { events: @events } %> <% end %> </div> </div> app/views/profiles/show.html.erb
  31. <div class="container py-8"> <%= render partial: "profiles/header", locals: { user:

    @user } %> <%= render partial: "profiles/topics", locals: { topics: @topics } %> <%= render partial: "profiles/navigation" %> <div class="mt-6"> <% if @talks.any? %> <%= render partial: "profiles/talks", locals: { talks: @talks } %> <% else %> <%= render partial: "profiles/events", locals: { events: @events } %> <% end %> </div> </div>
  32. <%= render partial: "profiles/header", locals: { user: @user } %>

    @ ERBRenderNode (location: (1:0)-(1:65)) └── keywords: └── @ RubyRenderKeywordsNode (location: (1:0)-(1:65)) ├── errors: [] ├── partial: "profiles/header" (location: (1:20)-(1:37)) ├── template_path: ∅ ├── inline_template: ∅ ├── body: ∅ ├── plain: ∅ ├── html: ∅ ├── renderable: ∅ ├── collection: ∅ ├── as_name: ∅ ├── spacer_template: ∅ ├── variants: ∅ └── locals: (1 item) └── @ RubyRenderLocalNode (location: (1:49)-(1:60)) ├── errors: [] ├── name: "user" (location: (1:49)-(1:54)) └── value: └── @ RubyLiteralNode (location: (1:55)-(1:60)) ├── errors: [] └── content: "@user"
  33. <%= render partial: "profiles/header", locals: { user: @user } %>

    @ ERBRenderNode (location: (1:0)-(1:65)) └── keywords: └── @ RubyRenderKeywordsNode (location: (1:0)-(1:65)) ├── errors: [] ├── partial: "profiles/header" (location: (1:20)-(1:37)) ├── template_path: ∅ ├── inline_template: ∅ ├── body: ∅ ├── plain: ∅ ├── html: ∅ ├── renderable: ∅ ├── collection: ∅ ├── as_name: ∅ ├── spacer_template: ∅ ├── variants: ∅ └── locals: (1 item) └── @ RubyRenderLocalNode (location: (1:49)-(1:60)) ├── errors: [] ├── name: "user" (location: (1:49)-(1:54)) └── value: └── @ RubyLiteralNode (location: (1:55)-(1:60)) ├── errors: [] └── content: "@user"
  34. _buf = ::String.new; _buf << '<ul>' @items.each do |item| _buf

    << '<li>' _buf << item.to_s _buf << '</li>' end _buf << '</ul>' _buf.to_s <ul> <% @items.each do |item| %> <li> <%= item %> </li> <% end %> </ul>
  35. _buf = ::String.new; _buf << '<ul>' @items.each do |item| _buf

    << '<li>' _buf << item.to_s _buf << '</li>' end _buf << '</ul>' _buf.to_s <ul> <% @items.each do |item| %> <li> <%= item %> </li> <% end %> </ul>
  36. All references - strict locals - path helpers - app

    view helpers - Action View Helpers = unresolved references
  37. The beautiful thing is that help you resolve these issues

    because we have the full understanding
  38. <%= turbo_frame_tag dom_id(@post) do %> <%= tag.div data: { controller:

    "hello" } do %> <%= link_to @post.title, post_path(@post) %> <% end %> <% end %>
  39. <%= turbo_frame_tag dom_id(@post) do %> <%= tag.div data: { controller:

    "hello" } do %> <%= link_to @post.title, post_path(@post) %> <% end %> <% end %>
  40. <%= turbo_frame_tag dom_id(@post) do %> <%= tag.div data: { controller:

    "hello" } do %> <%= link_to @post.title, post_path(@post) %> <% end %> <% end %>
  41. <%= turbo_frame_tag dom_id(@post) do %> <%= tag.div data: { controller:

    "hello" } do %> <%= link_to @post.title, post_path(@post) %> <% end %> <% end %>
  42. <%= turbo_frame_tag dom_id(@post) do %> <%= tag.div data: { controller:

    "hello" } do %> <%= link_to @post.title, post_path(@post) %> <% end %> <% end %>
  43. <%= turbo_frame_tag dom_id(@post) do %> <%= tag.div data: { controller:

    "hello" } do %> <%= link_to @post.title, post_path(@post) %> <% end %> <% end %>
  44. <%= turbo_frame_tag dom_id(@post) do %> <%= tag.div data: { controller:

    "hello" } do %> <%= link_to @post.title, post_path(@post) %> <% end %> <% end %>
  45. <%= turbo_frame_tag dom_id(@post) do %> <%= tag.div data: { controller:

    "hello" } do %> <%= link_to @post.title, post_path(@post) %> <% end %> <% end %>
  46. <%= turbo_frame_tag dom_id(@post) do %> <%= tag.div data: { controller:

    "hello" } do %> <%= link_to @post.title, post_path(@post) %> <% end %> <% end %>
  47. <h1><%= @post.title.upcase %></h1> <% if @user.admin? %> <p><%= @user.id %></p>

    <% end %> <%= link_to @post.title, post_path(@post) %>
  48. <h1><%= @post.title.upcase %></h1> <% if @user.admin? %> <p><%= @user.id %></p>

    <% end %> <%= link_to @post.title, post_path(@post) %>
  49. <h1><%= @post.title.upcase %></h1> <% if @user.admin? %> <p><%= @user.id %></p>

    <% end %> <%= link_to @post.title, post_path(@post) %>
  50. <h1><%= @post.title.upcase %></h1> <% if @user.admin? %> <p><%= @user.id %></p>

    <% end %> <%= link_to @post.title, post_path(@post) %>
  51. @post => [ "<h1><%= @post.title.upcase %></h1>", "<%= link_to @post.title, post_path(@post)

    %>" ] @user => [ "<% if @user.admin? %>", "<p><%= @user.id %></p>" ]
  52. What Herb knows: ✓ ~250 ActionView helpers (via registry) ✓

    Strict locals declarations ✓ Locals passed through render calls ✓ Instance variables (@post, @user) ✓ Constants (Current.user, Post.count)
  53. What Herb knows: ✓ Understand the render graph ✓ Trace

    state dependencies ✓ Resolving Action View Tag Helpers ✓ Track which variables affect which DOM nodes
  54. <%= turbo_frame_tag dom_id(@post) do %> <%= tag.div data: { controller:

    "hello" } do %> <%= link_to @post.title, post_path(@post) %> <% end %> <% end %> <turbo-frame id="<%= dom_id(@post) %>"> <div data-controller="hello"> <a href="<%= post_path(@post) %>"> <%= @post.title %> </a> </div> </turbo-frame> ↓
  55. Some of these results sound too good to be true,

    but I'm sure there is some performance to be gained
  56. The Ruby Prism Parser had a big effect on Ruby

    internals and the tooling landscape
  57. Thank you "  @marcoroth_  @[email protected] 🌐 marcoroth.dev 

    @marcoroth  @marcoroth.dev  /in/marco-roth