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

Your Views Deserve a Grammar @ Tropical on Rail...

Your Views Deserve a Grammar @ Tropical on Rails 2026, São Paulo, Brazil

Rails has always been the "batteries included" framework, but the view layer never got the same treatment. HTML+ERB has been loosely defined for 20 years. What happens when you give it a proper grammar? You get a parser, a toolchain, and eventually, reactivity.

From a parser written in C, to a linter with 60+ rules, a language server, a rendering engine, and browser dev tools, all powered by the same syntax tree. We'll trace the evolution from Turbolinks to Hotwire, show where the current approach hits a ceiling, and introduce Herb and ReActionView: a path to reactive, server-rendered Rails views without leaving ERB behind.

Avatar for Marco Roth

Marco Roth

April 09, 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. <%= tag.div data: { controller: "hello" } do %> <%=

    content_tag :input, data: { hello_target: "name"} %> <%= tag.button data: { action: "click->hello#greet" } do %> Greet <% end %> <%= tag.span data: { hello_target: "output" } %> <% end %>
  3. "#$

  4. ✓ syntax ✓ parser ✓ compiler or interpreter ✓ formatter

    ✓ linter ✓ language server ✓ package manager ✓ test runner ✓ CLI ✓ Interactive Playground ✓ Actionable error messages ✓ Editor integration ✓ Browser dev tools ...
  5. Rust + Cargo cargo new → project setup cargo build

    → compile cargo test → run tests cargo fmt → format code cargo clippy → lint code cargo doc → generate docs cargo bench → benchmarks cargo publish → release One tool. Everything built in.
  6. Go go build → compile go test → test runner

    go fmt → formatter (shipped day one) go vet → static analysis go doc → documentation go run → run directly go mod → dependency management gopls → language server No debates. No configuration. The language decided for you.
  7. Vite / VoidZero Ecosystem Vite → dev server, build orchestration,

    HMR | Rolldown → Rust bundler (replacing Rollup + esbuild) | OXC → Rust parser, linter (oxlint), transformer | Vitest → testing (built on Vite) VitePress → documentation (built on Vite) One parser. Shared infrastructure. Every tool benefits from every improvement.
  8. The Cost of Churn JavaScript (2015-2025): Grunt → Gulp →

    Webpack → Rollup → Parcel → esbuild → Vite Mocha → Jasmine → Jest → Vitest Bower → npm → yarn → pnpm → Bun → npm Backbone → Angular → React → Vue → Svelte → Solid → ... Every 2-3 years: rewrite your toolchain.
  9. 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
  10. 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
  11. What would it look like if we brought all of

    these ideas to the Rails view layer?
  12. This file contains THREE languages: 1. HTML → <div>, <h1>,

    <b>, class="..." 2. ERB → <% %>, <%= %> 3. Ruby → @user, if @show_details
  13. <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div> <div> <span><%=

    @user.firstname %></span> <b><%= @user.lastname %></b> </div>
  14. Ruby HTML <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div>

    <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div>
  15. Ruby HTML <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div>

    <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div>
  16. Ruby HTML @user.firstname @user.lastname <div> <span> </span> <b> </b> </div>

    t require "prism" Prism.parse("...") require "nokogiri" Nokogiri::HTML5.fragment("...")
  17. <h1>Title @ HTMLElementNode ├── errors: (1 item) │ └── @

    MissingClosingTagError │ ├── message: " │ │ Opening tag `<h1>` at (1:1) │ │ doesn't have a matching │ │ closing tag `</h1>`. │ │ " │ └── opening_tag: "h1" │ ├── open_tag: │ └── @ HTMLOpenTagNode │ ├── tag_opening: "<" │ ├── tag_name: "h1" │ ├── tag_closing: ">" │ ├── children: [] │ └── is_void: false │ ├── tag_name: "h1" ├── body: (1 item) │ └── @ HTMLTextNode │ └── content: "Title" │ ├── close_tag: ∅ └── is_void: false ^^^^^
  18. <h1>Title @ HTMLElementNode │── errors: (1 item) │ └── @

    MissingClosingTagError │ ├── message: " │ │ Opening tag `<h1>` at (1:1) │ │ doesn't have a matching │ │ closing tag `</h1>`. │ │ " │ └── opening_tag: "h1" │ ├── open_tag: │ └── @ HTMLOpenTagNode │ ├── tag_opening: "<" │ ├── tag_name: "h1" │ ├── tag_closing: ">" │ ├── children: [] │ └── is_void: false │ │── tag_name: "h1" │── body: (1 item) │ └── @ HTMLTextNode │ └── content: "Title" │ └── close_tag: ∅ └── is_void: false ^^^^^
  19. HTML-aware ERB Parser Since we have no grammer of what

    defines a valid HTML+ERB template
  20. <% if valid? %> <h1>Title</h1> <% end %> <% if

    valid? %> <h1>Title</h1> <% end %>
  21. <% if valid? %> <h1>Title</h1> <% end %> @ ERBIfNode

    ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  22. <% if valid? %> <h1>Title</h1> <% end %> @ ERBIfNode

    ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  23. <% if valid? %> <h1>Title</h1> <% end %> @ ERBIfNode

    ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  24. <% if valid? %> <h1>Title</h1> <% end %> @ ERBIfNode

    ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  25. <% if valid? %> <h1>Title</h1> <% end %> @ ERBIfNode

    ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  26. <% if valid? %> <h1>Title</h1> <% end %> @ ERBIfNode

    ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  27. This opens a lot of doors for understanding, analyzing, working

    with HTML+ERB documents. What If We Established a Grammar?
  28. 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
  29. Then we could: → Parse it (not just split it)

    → Analyze it (static analysis) → Lint it (catch mistakes) → Format it (consistent style) → Validate it (no invalid HTML) → Understand it (language server) → Compile it intelligently (optimizations) → Track changes (reactivity)
  30. ✓ syntax ✓ parser ✓ compiler or interpreter ✓ formatter

    ✓ linter ✓ language server ✓ package manager ✓ test runner ✓ CLI ✓ Interactive Playground ✓ Actionable error messages ✓ Editor integration ✓ Browser dev tools ...
  31. Parser → HTML+ERB parser Formatter → format-on-save in your editor

    Linter → 60+ rules (including accessibility) Language Server → diagnostics, highlights, code actions CLI → herb lint, herb format, herb analyze Playground → herb-tools.dev/playground (WebAssembly) Editor → VS Code extension, Zed, Neovim Error messages → annotated, syntax-highlighted, exact locations Runtime engine → Herb::Engine (drop-in for Erubi) Browser DevTools → view outlines, ERB hover, jump-to-source Highlighter → syntax highlighting engine Rewriter → automated refactoring Printer → lossless AST reconstruction
  32. If your editor tells you there is an error, you

    don't have to run the code to find out
  33. <%= tag.div data: { controller: "hello" } do %> <%=

    content_tag :input, data: { hello_target: "name"} %> <%= tag.button data: { action: "click->hello#greet" } do %> Greet <% end %> <%= tag.span data: { hello_target: "output" } %> <% end %>
  34. Having these rules and best practices encoded in tools like

    Herb, helps agents reason about this more efficiently and accurately
  35. <div> <h1><%= @title %></h1> <% if user_signed_in? %> <p>Welcome, <%=

    current_user.name %>!</p> <% else %> <p>Please <%= link_to "sign in", login_path %></p> <% end %> </div>
  36. - name: Herb Linter run: npx @herb-tools/linter - name: Herb

    Formatter run: npx @herb-tools/formatter --check .github/workflows/lint.yml
  37. Parser understands structure → Linter can check HTML validity →

    Formatter can indent correctly → Language server can navigate
  38. Engine compiles with structure → Invalid HTML won't compile →

    Debug attributes map DOM ↔ template → Error messages are precise
  39. Turbolinks → fast page transitions, no SPA needed Stimulus →

    modest JS for HTML you already have CableReady → fine-grained DOM operations from the server StimulusReflex → server-side reactivity over ActionCable Turbo Drive → Turbolinks evolved Turbo Frames → partial page updates, lazy loading Turbo Streams → server-pushed HTML fragments Turbo Morphing → DOM diffing without manual targets
  40. ✓ Simple mental model, server renders HTML ✓ No build

    step required ✓ Works with existing ERB views ✓ Progressive enhancement built in ✓ Turbo Frames for lazy-loaded sections ✓ Turbo Streams for real-time updates ✓ Stimulus for client-side behavior ✓ It just works for 80% of use cases
  41. ✓ State lives on the server ✓ One source of

    truth (your database) ✓ No client-side state sync bugs ✓ No stale data problems ✓ No hydration mismatches ✓ Authorization stays where it belongs
  42. This is enough for most Rails apps. This is why

    people choose Rails. This is why people choose Hotwire.
  43. To the point where it can feel like glue code

    held together with duct tape
  44. ✓ Keep Rails as the backend ✓ Use React/Vue/Svelte for

    the view ✓ Great DX for rich interactions ✓ Smooth transitions, client-side state ✓ You get the best of both worlds
  45. But: → You leave ERB and ActionView behind → You

    need a JavaScript framework → You need a build pipeline → Rails itself is no longer "full-stack"
  46. → Optimistic UI updates → Offline functionality → Lazy loading

    driven by the template → Instant interactions without full reloads → Beautiful, modern UX → Component-driven deferred rendering
  47. <ul> <% @items 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
  48. <ul> <% @items 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
  49. <ul> <% @items 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
  50. <ul> <% @items 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
  51. <ul> <% @items 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`
  52. <ul> <% @items 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>
  53. <ul> <% @items 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>
  54. <ul> <% @items 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>
  55. <ul> <% @items 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
  56. And can figure out the minimal set of DOM operations

    the reflect the change on the website
  57. The Ruby Prism Parser had a big effect on Ruby

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

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