
RubyKaigi 2026 - HTML-Aware ERB: The Path to Reactive Rendering (Day3)
こんにちは、今年Techouseに新卒入社し、エンジニアをしているez0momongaと申します。
2026年4月22日〜24日の3日間にわたって開催されたRubyKaigi 2026に参加してきました。
本記事では、3日目に行われたMarco Roth(@marcoroth)氏のセッション「HTML-Aware ERB: The Path to Reactive Rendering」を紹介します。 このセッションの主役は、Marco Roth氏が開発している、HTML+ERBに初めて文法を与えるパーサー「Herb」です。
本記事ではまず、背景知識としてERBエンジンの内部で何が起きているかを確認し、Herbがそれをどう変えたかを整理します。 その上で、今回のセッションで発表されたProduction Reactivityの道筋と、すでに動き始めた実用的な成果を紹介します。
前提: ERBはHTML構造を理解していない
普段Railsアプリケーションを開発していると、.html.erbファイルにおいてHTMLへRubyコードを埋め込むのはよくある作業です。しかし、その裏側でERB(embedded Ruby)エンジンがそのテンプレートをどのように処理しているかを意識する機会は少ないのではないでしょうか。
Erubi::Engineの仕組み
Railsが標準で使用しているERBエンジンはErubiです。Erubi::Engineはテンプレートを正規表現で処理します。ERBタグ(<%= %>や<% %>)を目印にテンプレートを「静的パーツ」と「動的パーツ」に分割し、それぞれを文字列連結のコードに変換します。
セッションで示された例を見てみましょう。
<h1>Hello, <%= name %>!</h1>
Erubi::Engineは正規表現でERBタグを検出し、静的パーツと動的パーツに振り分けて、最終的に以下のようなRubyコードを生成します。
_buf = ::String.new # 出力バッファ(最終的なHTML文字列を蓄積する) _buf << '<h1>Hello, ' # 静的パーツ(テキスト) _buf << ::Erubi.h(name) # 動的パーツ(Ruby式)。Erubi.h は HTML エスケープ _buf << '!</h1>' # 静的パーツ(テキスト) _buf.to_s
つまり、Erubi::Engineがやっていることは文字列の連結です。
<h1>が開きタグであること、</h1>が閉じタグであること、それらが正しく対応していること——そうしたHTMLとしての構造は一切認識していません。
これは開発のさまざまな場面で不便をもたらします。
ERBエコシステムが抱えていた課題
- 構文エラーが実行時まで検出されない: 閉じタグの書き忘れや
<% end %>の記述漏れがあっても、実際にレンダリングされるまでエラーは発覚しない - エラーメッセージから問題箇所を特定しづらい: エラーメッセージはERBから変換された後のRubyコードの行番号を指すため、元のテンプレートでの位置特定が困難
- エディタの支援機能の恩恵を受けにくい: ERBがテキストとして扱われているため、構文ハイライトやエラー検出、コード補完が機能しない
こうした問題の根本原因は、HTML+ERBという混在フォーマットに対してまともなパーサーが存在しなかったことにあります。
Herbはどのようにそれを解決したか
Rubyの世界では、Prismといったパーサーの登場がRuby LSPをはじめとするツールチェーンを一変させました1。しかし、ビューレイヤーには同様のパーサーが存在せず、ツールを構築する対象がない状態でした。
Herbはこの問題に正面から取り組みました。まず文法を定義し、パーサーを作るところから始めたのです。
文法の確立
Herbが最初に行ったのは、HTML+ERBに対する正式な文法とパーサーの構築です。
- 文法(grammar): ある言語において、何が正しい構文で何が不正な構文かを定める規則の集合。HTML+ERBにはこれまで正式な文法が存在しなかった
- パーサー(parser): 文法の規則に従ってソースコードを解析し、構造を階層的に表現した構文木(AST)へ変換するプログラム
HerbのパーサーはPrismと役割を分担しており、RubyコードはPrism、HTMLの構造はHerbがそれぞれ検証します。この連携により、以下のエラーをすべて実行前に検出できるようになりました。
| 検出対象 | 担当 |
|---|---|
<% end %>の記述漏れ |
Herb + Prism |
| Rubyの構文エラー | Prism |
| HTMLの閉じタグ忘れ | Herb |
さらに、パーサーが元テンプレートの位置情報を保持しているため、エラーメッセージは変換後のRubyコードではなく.html.erbの該当行を直接指します。
セッションではこれを「One parser. One truth.」と表現していました。エディタのLSP、CLIツール、アプリケーションのエンジンがすべて同じパーサーと構文木を共有するため、エディタで出るエラーは実行時にも同じエラーになります。前セクションで挙げた構文ハイライトやコード補完に加え、Linter、Formatterといったツールチェーンもこの基盤の上に構築されています。
Herb::Engine — 文字列テンプレーティングからHTMLテンプレーティングへ
文法とパーサーが確立されたことで、ERBエンジンそのものを再設計できるようになりました。 従来のErubi::Engineはテンプレートをテキストとして扱い、正規表現で静的パーツと動的パーツに分割する設計でした。 しかしパーサーがある世界では、テンプレートをいったんAST(抽象構文木)に変換し、HTMLの構造を理解したうえで同等の出力を生成するという、まったく異なるアプローチが取れます。 この発想で作られたエンジンがHerb::Engineです。

セッションではこの変化を、「テンプレートを単なるテキストとして扱うString Templatingから、HTMLとして扱うHTML Templatingへの転換」と表現していました。
なお、この再設計は従来のErubi::Engine APIとの互換性を保ったまま行われており、既存の.html.erbファイルはそのまま動作します。
パースとAST
リスト1のテンプレートをパースすると、以下のASTが得られます。
@ DocumentNode
└── @ HTMLElementNode
├── open_tag:
│ └── @ HTMLOpenTagNode (<h1>)
├── body:
│ ├── @ HTMLTextNode ("Hello, ")
│ ├── @ ERBContentNode (<%= name %>)
│ └── @ HTMLTextNode ("!")
└── close_tag:
└── @ HTMLCloseTagNode (</h1>)
HTMLの要素構造、開きタグ・閉じタグの対応、テキストノードとERBノードの区別が、すべて木構造として表現されています。Erubiが正規表現で切り出した「ただの文字列の断片」とは根本的に異なり、各パーツがHTMLとしての意味を持っています。
Visitorパターンによるコンパイル
Herb::Engineは、このASTをVisitorパターンで走査してコンパイルします。Visitorパターンとは、ASTの各ノード型に対応するメソッドを定義し、木を走査しながら処理を振り分ける手法です。
class Compiler < Herb::Visitor def visit_html_open_tag_node(node) add_text(node.tag_opening) # "<" add_text(node.tag_name) # "h1" add_text(node.tag_closing) # ">" end def visit_html_text_node(node) add_text(node.content) # "Hello, " end def visit_erb_content_node(node) if node.tag_opening == "<%=" add_expression(node.content) # "name" else add_code(node.content) end end end
Visitorが各ノードを走査すると、ノードごとに命令が生成されます。ここから連続するテキスト命令を結合する最適化を行うと、最終的な出力は以下のようになります。
add_text("<h1>Hello, ") add_expression("name") add_text("!</h1>")
これはリスト2のErubiの出力と構造的に同等です。ASTを経由しても最終出力が変わらないからこそ、既存テンプレートとの互換性が保たれています。
文法があるから可能になったこと: Production Reactivity
文法とパーサーという基盤が整ったことで、これまで不可能だった機能が実現し始めています。 昨年のRubyKaigiではパーサーの構築が主題でしたが、今回のセッションではその上に築かれた新機能が発表されました。 当日リリースされたHerb v0.10には、この後紹介するProduction Reactivityの基本要素がすべて含まれています。
Reactivity(リアクティビティ)とは
多くのUIフレームワークは、状態(state)が変わったときにUIの該当箇所を自動的に更新する仕組みを持っており、これをReactivity(リアクティビティ)と呼びます。
その実現方法は大きく2つに分かれます。
- 仮想DOM差分検出(例: React): 状態変更時に仮想DOMツリー全体を再構築し、前回との差分を検出して実DOMに反映する
- 依存グラフ追跡(例: Solid.js): 状態の依存関係をグラフとして追跡し、変更が影響するDOMノードだけを直接更新する
Herbが目指すのは、Phoenix LiveViewやLaravel Livewireと同じく、依存追跡型のアプローチによってリアクティビティをサーバー側でfine-grained(細粒度)に実現することです2。
これらの依存追跡型フレームワークに共通する前提は、エンジンが自身の描画内容を構造的に理解しており、依存関係のグラフを保持していることです。どの状態がどのUIに影響するかを把握しているからこそ、変更があったときに該当箇所だけを効率よく更新できます。
セッション全体を通じて立ち返っていたのは、「Hotwireの物語を完成させたい」というモチベーションでした。サーバーサイドレンダリングのHTMLを維持したまま、SPAを書かずに、フルスタックRubyのまま、リアクティビティを実現できないか——これがHerbの目指す方向です。
リアクティビティに必要な5つのChallenge
その道筋を、以下のAction Viewテンプレートを例に見ていきましょう。
<%= turbo_frame_tag dom_id(@post) do %> <%= tag.div data: { controller: "hello" } do %> <%= link_to @post.title, post_path(@post) %> <% end %> <% end %>
@post.titleが変わったとき、理想的にはテンプレート全体ではなく、この値を表示しているHTML要素だけを更新したいところです。
しかし@post.titleがどのHTML要素の中に入るかは、link_toといったAction Viewヘルパーの展開結果に依存しており、ERBテンプレートからは直接見えません。
そのため、更新すべき箇所を特定するには、テンプレートから複数の情報を読み取る必要があります——セッションではこれを5つのChallengeに分解しています。
| Challenge | 問い |
|---|---|
| Render Graph | @post.titleを参照しているファイルはどれか |
| State Dependencies | そのファイル内で@postに依存するタグはどれか |
| Reference Resolution | link_toはそれ自体を追跡すべきか、引数を追うべきか |
| Tag Helpers | turbo_frame_tagは実際にどんなDOM要素を作るか |
| Reverse Dep Index | @post→更新すべきDOM要素はどれか |
1. Render Graph(レンダーグラフ)の理解
状態が変わったときの影響範囲を特定するには、まずどのテンプレートとパーシャルが描画に関与しているかを把握する必要があります。
Herbはrender呼び出しをASTレベルで解析し、パーシャル名や渡されるローカル変数を構造化された形で取得できます。
<%# app/views/profiles/show.html.erb %> <%= render partial: "profiles/header", locals: { user: @user } %> <%= render partial: "profiles/topics", locals: { topics: @topics } %>
Herbはここから以下のようなレンダーグラフを構築します。
profiles/show ├── profiles/header (user: @user) └── profiles/topics (topics: @topics)
2. State Dependencies(状態依存)の追跡
関与するファイルがわかったら、次はテンプレートの各部分がどの変数に依存しているかを把握する必要があります。 ERBはこれに適した性質を持っています。
<%= ... %>タグは常に「何らかの状態を受け取り、文字列を返す」というシンプルな契約に従います。
たとえば<%= item %>は内部で_buf << (item).to_sに変換されます。
入力がitemで出力が文字列——この明確な入出力関係が、依存の追跡を可能にします。
3. Reference Resolution(参照の解決)
テンプレート内の識別子は、値が変わりうる動的な状態と、入力が決まれば出力も決まる静的な関数に分類できます。この区別により、ヘルパーを透過して実際の状態(引数)まで依存を追跡できるようになります。
| 種類 | 例 | 追跡対象 |
|---|---|---|
| 動的な状態(変数) | user, @post |
変数自体が再描画の起点 |
| 静的な関数(ヘルパー) | link_to, user_path |
ヘルパー自体ではなくその引数 |
Herbはまず、Prismを使ってローカル変数、インスタンス変数、メソッド呼び出しなどを分類します。
さらに、約250のAction Viewヘルパー(link_to、form_with等)を登録したレジストリ(Action View Helper Registry)を構築し、メソッド呼び出しがヘルパーかどうかを静的に判定可能にしました。
すべての参照から既知のもの(strict locals3、パスヘルパー、Action Viewヘルパー等)を差し引いた残りが「未解決の参照」です。 この解決はルート単位で段階的に行えます。リアクティビティの精度が必要なページだけで解決を進めればよく、未解決の参照に対してはHerbが警告を表示します。
4. Tag Helpersの展開
識別子の正体がわかっても、Action View Tag Helpers(turbo_frame_tagやtag.divなど)は他のERBタグと同じく文字列を返す式にすぎず、内部のHTML構造が見えません。冒頭のテンプレート(リスト6)では3つのヘルパーがネストしていますが、どのヘルパーがどのHTML要素を生成するか、ERBの側からは把握できません。
Herbはこれらのヘルパーを検出し、生成されるHTMLに展開できます。
<turbo-frame id="<%= dom_id(@post) %>"> <!-- turbo_frame_tag の展開 --> <div data-controller="hello"> <!-- tag.div の展開 --> <a href="<%= post_path(@post) %>"> <!-- link_to の展開 --> <%= @post.title %> <!-- 更新対象 --> </a> </div> </turbo-frame>
こうしてHTML構造が見えるようになれば、@post.titleが変わったときに<a>タグの中身だけを更新すればよいと判断できます。
5. Reverse Dependency Index(逆依存インデックス)
ここまでは「テンプレート→変数」の方向で依存を追跡してきましたが、再描画には「変数→DOMノード」の逆引きが必要です。
<h1><%= @post.title.upcase %></h1> <% if @user.admin? %> <p><%= @user.id %></p> <% end %> <%= link_to @post.title, post_path(@post) %>
このテンプレートに対して、Herbは以下のような逆依存インデックスを構築します。
@post => [ "<h1><%= @post.title.upcase %></h1>", # @post.title を参照 "<%= link_to @post.title, post_path(@post) %>" # @post.title, post_path(@post) を参照 ] @user => [ "<% if @user.admin? %>", # @user.admin? で分岐 "<p><%= @user.id %></p>" # @user.id を参照 ]
@postが変更されたとき、影響を受けるのは<h1>タグとlink_toの部分だけであり、@userに関連する部分は更新不要です。
このインデックスにより、状態変更に対する必要最小限のDOM更新を計算できます。
Production Reactivityの現在地
セッションでは、Production Reactivityはまだ完成していないと述べられていました。 現時点で揃ったのはプリミティブ(基本要素)であり、これらのプリミティブを結合してはじめて、サーバーサイドでのリアクティビティが実現します。
しかし、Production Reactivityに向けた作業の過程で、すでに実用的な成果が生まれています。
herb dev: 開発時のホットリロード
herb devは、Vite Dev Serverのように、ERBテンプレートの変更をブラウザに即座に反映する仕組みです。
herb devの差分検出を担うHerb.diffは、行単位のテキストdiffとは異なり、AST(構文木)レベルで差分を検出します。
これにより「属性が追加された」「テキストノードの中身が変わった」といった、構文的に意味のある変更を識別できます。
実際にherb devコマンドを起動すると、ERBテンプレートを保存するたびに、内部でHerb.diffがAST差分を計算し、フルページリロードなしに変更箇所だけがブラウザに反映されます。
コンパイル時最適化
テンプレートの構造をASTとして把握する仕組みは、リアクティビティだけでなくコンパイル時の最適化にも活用できます。セッションではこれを「リアクティビティの作業の副産物」と表現していました。
Action View Tag Helpersのプリコンパイル
Action View Tag Helpersの展開は、リアクティビティだけでなくパフォーマンスにも恩恵があります。展開後のテンプレートを改めてパフォーマンスの視点で見てみましょう。
<turbo-frame id="<%= dom_id(@post) %>"> <%# 動的: 実行時に評価 %> <div data-controller="hello"> <%# 静的: コンパイル時に確定 %> <a href="<%= post_path(@post) %>"> <%# 動的: 実行時に評価 %> <%= @post.title %> <%# 動的: 実行時に評価 %> </a> </div> </turbo-frame>
静的な部分(<div data-controller="hello">など)はコンパイル時にHTML文字列として確定し、実行時にヘルパーを呼び出すオーバーヘッドがなくなります。
セッションでは、初期段階の数値であると前置きされていたものの、以下のベンチマークが示すように、現実的なページレイアウトでも約22倍、ヘルパー中心のテンプレートでは約151倍と大幅な高速化が示されていました。
renderコールのインライン化
もう1つの最適化は、render呼び出しのインライン化です。
通常、renderを呼ぶたびにRailsはテンプレートファイルの検索やlocalsの組み立てを実行時に行います。一方、Herbのレンダーグラフによってどのパーシャルが呼ばれるかが既に判明しているため、Herb::Engineはその検索結果をコンパイル時に展開し、コンパイル済みメソッドの直接呼び出しに置き換えられます。
<%# 変換前: 実行時にテンプレートを検索 %> <%= render partial: "posts/post", locals: { post: post } %> <%# 変換後: コンパイル済みメソッドを直接呼び出し %> <%= _app_views_posts__post_html_erb_[digest]_[number](post) %>
この置き換えだけで、一部のケースでは20倍の高速化が確認されています。
まとめ
セッションの結びでは、PrismがRubyのツールエコシステムを一変させたように、HerbはHTMLテンプレーティングにも同様の変化をもたらしうると語られていました。
実際、herb dev、コンパイル時最適化、Production Reactivityは一見それぞれ別の機能に見えますが、いずれも「HTML+ERBに文法を与えた」という一点から派生しています。 文法があるからASTが作れる。ASTがあるから構造を理解できる。構造を理解しているから、差分検出も、最適化も、依存追跡もできる——一見地味なパーサーの仕事が、その先のすべてを可能にしています。
普段の業務でAction Viewのテンプレートを書いている身としては、この基盤の上にHotwireの物語がどう展開していくのか、とても楽しみです。
最後になりますが、本記事を執筆するきっかけとなったMarco Roth(@marcoroth)さんのセッション、そしてRubyKaigi 2026 運営の皆様にこの場を借りて感謝を申し上げます。
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。
- パーサーがソースコードを構文木(AST)に変換することで、エディタのLSPなどはコードの構造を理解した上で診断・補完・定義ジャンプといった機能を提供できるようになります。↩
- Phoenix LiveView(Elixir)はWebSocketで常時接続し、サーバー側で状態が更新されると変更部分の差分をクライアントに送ってDOMを更新します。Laravel Livewire(PHP)はUIイベントごとにHTTPリクエストで通信し、各リクエストでコンポーネントの状態を復元して再レンダリングし、返ってきたHTMLを既存DOMに差分マージして反映します。いずれもステート管理とレンダリングをサーバー側に集約する設計です。↩
-
strict localsは、Rails 7.1で導入された機能です。テンプレートの先頭に
<%# locals: (user:, title: "Default") %>のようなマジックコメントを記述することで、そのテンプレートが受け取るローカル変数を明示的に宣言できます。↩