🐥

Ruby以外の言語でHotwireを使ってフロントエンドを構築する

2024/01/28に公開

HotwireはRails7から同梱されるようになったフロントエンド用のライブラリ群です。Rails作者であるDHHの所属する37signalsのメンバーが主体となって作られており、Railsとセットで使われるイメージがあったりするかもしれませんが、実はそうではなくそれぞれ単体で使えるライブラリになっていて言語やフレームワークに関係なくフロントエンドに導入できます。

そこでRails以外でも充分活用できることを示すためGo言語での実装例を挙げつつ紹介記事を書きます。

Hotwireとは

そもそもHotwireとは単一のライブラリではなくHotwireという設計思想に則ったライブラリ群の総称です。

以下の3つから構成されます。

Stradaはモバイルアプリを構築するためのライブラリです。今回はTurboとStimulusについて扱います。

Turbo

TurboはHTMLをベースにレンダリングするフロントエンドライブラリです。当たり前のことではないかと思う人もいるかもしれませんが、現代のWebアプリケーションでは初回の表示はHTMLを読み込んだとしても以降の表示はAPIから読み込んだ値を元にページ内の要素を動的に構築するようなやり方が流行っています。例えばReactで構築したSPAなどがそれにあたります。

Turboのアプローチはそれとは異なりサーバーが返したHTMLを元にページ内の要素を動的に差し替えます。

そのままブラウザでページ遷移するのと何が違うのかと思われるかもしれませんが、
ブラウザのページ遷移は画面全体を再構築します。一方でSPAではDOM要素だけを差し替えるため効率がよいと言われています。

TurboはSPAの動的に差し替える戦略を採用していますが、サーバーが返したHTMLをそのまま差し替える要素として使います。

このやり方のメリットはいくつかあって、もちろん一番大きなメリットはJavaScriptを書く必要がなく静的なフロントエンドのように扱うことが出来るということです。Turboを読み込むと自動でフォームのsubmitやリンククリックなどのDOMイベントを監視して、ブラウザリクエストを差し替え、代わりにHTMLをfetchして、bodyだけを差し替える処理を実行します。なのでプログラマはコードを書く必要がなく、サーバーでHTMLを返す実装だけを書けば良いわけです。

また、ページ遷移についてもSPAと同様DOMが書き換わるだけなので読み込みによる切れ目のないスムーズな遷移になります。

さらに、サーバーの要件はHTMLをレンダリングすることだけなので、言語やテンプレートエンジンは好きに選択できます。JavaScriptでレンダリングする要素を構築するようなライブラリではサーバサイド・レンダリングしようとする場合当然サーバサイドもJavaScriptで動かさなければいけませんがそのような縛りはありません。極端な話、静的ページでも動作します。静的ページもHTMLを返すという意味では同じですから。実際にHugoなどの静的サイト・ジェネレータにTurboを導入している例も見かけます。

他のメリットとしては、HTMLに埋め込む値をいちいちJSONにエンコードして、フロントエンド側でデコードする作業が必要がないというのもメリットです。自分はこの作業を毎回無駄に感じているので、Turboでその必要がなくなったのはメリットでした。

最後に一つ強調したいのが、このページ遷移の自動差し替え(Turbo Driveと呼びます)については、Turboの機能がオフになったとしても、通常のブラウザによるページ遷移に戻るだけなので、アプリケーションとしては変わらず機能を提供できるということです。ユーザーの環境に合わせて可能な限り機能、コンテンツを提供していくようなアプリケーションの設計思想をプログレッシブ・エンハンスメントと呼びますが、Turboはそのような設計に非常に適しています。例えばJavaScriptが使えない環境でも、サーバサイドで完結したアプリケーションとしては機能させることができるわけです。

Turbo Frames

Turbo Framesはページの共通要素は書き換えずに部分だけを書き換えるためのTurboの機能です。
大抵のWebアプリケーションでは、外側にヘッダーやメニュー、フッターなどの共通要素があり、個別のページは中の一部分だけコンテンツが異なるような作りをしていると思いますが、そのような場合に差分のコンテンツだけを書き換えることができます。

こちらもJavaScriptを記述する必要はなく、<turbo-frame>というタグで差し替えたい要素を囲むだけで自動でTurboが差し替えてくれます。

もちろんTurboがオフになっても全画面遷移に変わるだけなのでアプリケーションの機能としては維持されます。

Turbo Streams

Turbo Framesの制限として、一度に差し替えられるコンテンツは一箇所だけというものがあります。

しかし、複数の箇所を同時に差し替えたり、差し替えではなく追加したりしたい場合もあると思います。
そのような場合に使えるのがTurbo Streamと呼ばれる機能です。

Turbo Streamsではサーバサイド側はHTMLの一部分だけを配信します。
そしてTurboのクライアントサイドでは受け取ったHTML要素をタグの属性などを元に差し替えたり追加したりします。
WebAPIの一つのレスポンスに複数の部分コンテンツを詰め込めて、差し替え、追加、削除などをそれぞれ個別に指定できるので柔軟に動的要素を構築できます。
また、コンテンツを配信する手段はHTTPレスポンスでなくとも構わないので、WebSocketなどでストリーム配信したり、ブラウザ上で生成したコンテンツをそのままブラウザ内で配信したりとなんでもできることもメリットです。

Turbo Streamsはメリットも多いですが、読み込むだけで自動で動いたり、Turboをオフにしても変わらない機能を提供できるというわけではありません。また、Rails以外でTurbo Streamsを使った機能を実装する場合は、JavaScriptコードで明示的に読み込み処理を書かなければいけないので、少しだけ手間が増えてしまいます。必要に応じてTurbo DriveやTurbo Framesと使い分けると良いでしょう。

後ほど実際のチャットアプリケーションを例に、動的にコンテンツを読み込む実装について解説します。

Stimulus

StimulusはDOMツリーを監視して自動でイベントハンドラと結びつけるライブラリです。
StimulusはTurboとは全く独立して動作しますが、Turboの補完としてもうまく機能します。

Turboではサーバサイドで生成したHTMLをそのままレンダリングします。クライアントサイドでレンダリング要素を構築するやり方と比較して一つデメリットを挙げるとするとDOM要素と動的なイベント処理を結びつけることが難しくなることです。

例えば、サーバサイドでレンダリングしたHTMLの要素にイベントリスナーを追加する場合、一般的によくある実装としては、コンテンツの読み込みが終わった後にJavaScriptで初期化処理を実行し、その中で要素ごとにaddEventListenerを呼び出して追加します。

ブラウザでページ遷移をするような場合は、遷移ごとにページが再ロードされ初期化処理も再実行されます。一方ページ内でDOMを差し替える場合はDOMを差し替えるごとにイベントリスナーも追加し直す必要があります。

クライアントサイドでレンダリング要素を構築する場合も同様ですが、例えばReactの場合、イベントハンドラの生成はコンポーネント内で実行されコンポーネントとセットで構築されます。もしDOM要素に差分があり差し替えが発生したとしても自動でイベントリスナーも付け直されるためプログラマが意識する必要はありません。

しかしTurboの場合、JavaScriptなしで実行する場合は楽なのですが、JavaScriptによるイベントハンドラーの実装を伴う場合、差し替えた要素ごとに手動で付け替える必要があります。
差し替える要素内にスクリプトタグを仕込んでDOM差し替えごとに初期化スクリプトを実行するという手もありますが、煩雑ですし、状態管理も複雑になりがちです。
Turboの全身のTurbolinksというライブラリはこの辺りがデメリットとして捉えられたために不人気でした。

しかしStimulusの登場によってそのような状況でもうまくハンドリングできるようになりました。

Stimulusの具体的な動作は、DOMの追加削除を監視し、条件を満たすDOMが追加されたときに自動でコントローラーと呼ばれるオブジェクトを「アタッチ」します。そして削除されたときには「デタッチ」します。

コントローラーはイベントハンドラの集合体として振る舞います。
このため、イベントハンドラの追加削除を行う必要がなくなり、またイベントハンドラの管理はコントローラーに集約されるので状態管理や処理の追加修正もやりやすくなります。
さらに、コントローラはイベントハンドラだけではなく、アタッチ対象の要素やその子孫を管理できます。
このため単なるイベント処理だけではなくビューを操作したり管理したりする役割も担うことができます。

画面上の動的な要素を構築、管理するための部品として便利に使える再利用性の高いライブラリがStimulusです。

StimulusとTurboの設計についての私見

ここまでざっと2つのライブラリを解説してきましたが、この2つには共通するアーキテクチャがあります。
それはObserverパターンによってDOMイベントを監視し、動的なイベントハンドリングを実行すると言うことです。

Turboの場合はフォームのsubmitとリンクのclickイベントを監視して、ブラウザーによる画面遷移を差し替えます。
Stimulusの場合はMutationObserverでDOMの変更を監視して、DOMにコントローラーをアタッチ/デタッチします。
そしてコントローラーはDOMイベントを受け取ってイベントハンドリングを行います。

どちらもDOMイベントをうまく状態の伝播に使っておりまるでブラウザがメッセージバスになっているかのようです。
JavaScriptで処理が閉じているよりもブラウザのAPIを経由した方が当然ながら処理が軽いため、パフォーマンス的にもメリットがあると思っています。

Hotwireのライブラリについては最近のフロントエンドのトレンドに反しているかのようによく言われるのですが、むしろモダンなブラウザ技術を使ってブラウザにうまく処理を任せる前提の設計になっておりモダンWebの流れにはむしろ乗っかっているのではないかと思います。

Goでの実装例

Goで非常に簡単なチャット・アプリケーションを実装してみます。

基本のチャットアプリ

まずGoで最小限のコードしか持たないチャット・アプリを用意します。
外部依存としてはルーターのchiしか使っていません。

最初のサンプル・コード全体は以下にあります。
https://github.com/minoritea/chat-example/tree/step1

main.go

package main

import (
        "embed"
        "html/template"
        "net/http"

        "github.com/go-chi/chi/v5"
)

//go:embed template/*.html
var fs embed.FS

var messages []string

func GetIndex(w http.ResponseWriter, r *http.Request) {
        t, _ := template.ParseFS(fs, "template/index.html")
        t.Execute(w, map[string]any{"Messages": messages})
}

func PostMessage(w http.ResponseWriter, r *http.Request) {
        r.ParseForm()
        message := r.FormValue("message")
        messages = append(messages, message)
        http.Redirect(w, r, "/", http.StatusFound)
}

func main() {
        r := chi.NewRouter()
        r.Get("/", GetIndex)
        r.Post("/messages", PostMessage)
        http.ListenAndServe("127.0.0.1:8888", r)
}

template/index.html

<!DOCTYPE html>
<html>
  <head>
    <title>Chat Example</title>
  </head>
  <body>
    <div id="chat">
      <div id="messages">
        {{ range .Messages }}
          <div class="message">
            <span class="text">{{ . }}</span>
          </div>
        {{ end }}
      </div>
      <form action="/messages" method="post">
        <input type="text" name="message" id="message" />
        <input type="submit" value="Send" />
      </form>
    </div>
  </body>
</html>

このアプリは単純なフォームからメッセージを投稿し、投稿されたメッセージをリストとして表示する機能のみを持っています。
投稿されたmessagesはパッケージ変数に保存され永続化はされませんが、一応最小限の投稿表示機能は持ち合わせています。
しかしこのままでは投稿毎に画面遷移が発生しスムーズなチャットにはなりません。

そこで、Turboを導入してみます。

Turbo Drive

<head>
  <title>Chat Example</title>
  <script type="module" src="https://cdn.skypack.dev/@hotwired/turbo" crossorigin></script>
</head>

基本的にはこれだけでOKです。Turbo Driveは全く導入コストがかからずスクリプトタグを入れるだけで済みます。

コード全体はこちら
https://github.com/minoritea/chat-example/tree/step2

次にmessageを投稿したとき、メッセージ一覧だけを再読み込みするように変更してみます。

Turbo Frames

テンプレートのbodyの中身を以下のように変更します。

<div id="chat">
  <turbo-frame id="message-frame">
    <div id="messages">
      {{ range .Messages }}
        <div class="message">
          <span class="text">{{ . }}</span>
        </div>
      {{ end }}
    </div>
  </turbo-frame>
  <form action="/messages" method="post" data-turbo-frame="message-frame">
    <input type="text" name="message" id="message" />
    <input type="submit" value="Send" />
  </form>
</div>

差分はmessagesをturbo-frameタグで囲ったことと、フォームにdata-turbo-frame属性をつけたことだけです。
これだけでコンテンツの部分読み込みに対応できています。

しかしフォームをリロードしなくなったことで入力したフォーム値がクリアされなくなりました。
submit後にフォームをクリアするようにStimulusを導入してみます。

Stimulus

まずheadに以下のscriptを挿入します。

<script type="module">
  import { Controller, Application } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"

  class SubmitController extends Controller {
    static targets = [ "text" ]
    afterSubmit() {
      this.textTarget.value = ""
    }
  }

  const application = Application.start()
  application.register("submit", SubmitController)
</script>

分かりやすいようにインラインで入れていますが外部ファイルに分けて読み込んでも構いません。
まず一つ目はコントローラーです。
StimulusのコントローラーはControllerを継承したクラスとして定義します。
targetsとafterSubmitの意味は後で説明します。

Stimulusを単体で利用する場合はApplication.startでStimulusを起動する必要があります。
そしてregisterでコントローラーをStimulusに登録します。
Stimulusを利用する上でJavaScriptを利用しないといけないのはここまでです。

つぎにコントローラーをアタッチするform要素を以下のように書き換えます。

<form action="/messages" method="post"
  data-turbo-frame="message-frame"
  data-controller="submit"
  data-action="turbo:submit-end@document->submit#afterSubmit">
  <input type="text" name="message" data-submit-target="text"/>
  <input type="submit" value="Send" />
</form>

HTML要素にdata-controller属性をつけるとdata-controllerで指定したコントローラーが自動でアタッチされるようになります。
さて、このコントローラーでやりたいことはフォームの送信後にメッセージ入力欄をクリアすることです。

そのためにはコントローラーが子要素のinputタグを見つける必要があります。
アタッチされた要素自体はコントローラーからアクセスすることが出来ます(this.element)。
そこからquerySelectorなどで探してもいいのですが、Stimulusにはコントローラーから特定の要素にアクセスするための便利な方法が用意されています。

コントローラーがアタッチされた要素の子要素にdata-[コントローラー名]-target="ターゲット名"という属性をつけると、
コントローラー側にターゲット要素として登録されます。

ちなみにコントローラー名はクラス名ではなくregisterでStimulusに登録したときの名前を指定します。
またターゲット名はコントローラー側でtargetsという静的プロパティに予め入れておく必要があります。

class SubmitController extends Controller {
  static targets = [ "text" ]

さてメッセージ入力欄のクリアはsubmitの後に実行したいですね。
turboにはカスタムイベントとしてturbo:submit-endイベントが定義されています。
このイベントに対してイベントハンドラを実装します。
イベントハンドラとなる処理は単にコントローラーのパブリックメソッドとして用意すればよいです。

    afterSubmit() {
      this.textTarget.value = ""
    }

afterSubmitというメソッドをSubmitControllerに作成します。
さて、この中で先ほど設定したtextターゲットを呼び出します。
Stimulusのコントローラーではターゲットに対応して[ターゲット名]Targetというゲッターが自動的に生えてきます。
このあたりはRuby on Railsっぽい動きですね。
textTargetゲッターからtextターゲット要素にアクセスできるのでvalueを空にします。

さてこのafterSubmitメソッドをturbo:submit-endイベントと結びつけます。このようなイベントとメソッドの結びつきをアクションと呼びます。
アクションを登録するためには、イベントが発生する要素にdata-action属性を付けます。

<form action="/messages" method="post"
  ...
  data-action="turbo:submit-end@document->submit#afterSubmit">

turbo:submit-end@document->submit#afterSubmitという構文の意味ですが、まずturbo:submit-endが今回トリガーにしたいカスタムイベントの名前になります。
ところでdata-actionはイベントが発生する要素につけると書きましたが、turbo:submit-endはdocumentで発生するため、コントローラーの要素や子孫要素からはアクセスできません。
そのような場合にdocumentで発生するイベントをハンドリングするため、@documentというサフィックスが用意されています。
@documentをつけるとdata-actionが定義された要素ではなくdocumentからイベントを拾うようになります。

ちなみに@windowというイベントサフィックスも用意されていて同様にwindowのイベントもハンドリングできます。

->の後に書いてあるのはコントローラー名とメソッド名を#で繋いだものです。これによって起動するメソッドを指定します。

これによってメッセージ送信後に入力値がsubmitされるようになりました。

ここまでのコード全体はこちらになります。
https://github.com/minoritea/chat-example/tree/step3

Turbo Streams

Turbo FramesによってSubmit後に毎回メッセージ一覧を再取得するようになりましたが、
これだと読み込みごとに全件取得になってしまいます。
追加したメッセージだけを読み取りたいですね。

そのような場合はTurbo Streamsで部分的な要素だけを読み取りできます。

メッセージの取得処理を以下のように修正します。

  1. 表示しているメッセージのIDを連番で取得する
  2. フォーム送信時に表示している最後のメッセージIDを指定する
  3. サーバーは指定されたメッセージIDより新しいメッセージがあればそれを返す

まずメッセージに連番を振るようにします。
Message型の構造体を用意し、messagesのスライスも[]string型から[]Message型に変えます。
また連番管理用のmessageIDというパッケージ変数を用意します。

type Message struct {
        ID   string
        Body string
}

var messages []Message
var messageID int

またMessage送信時の処理も、IDをインクリメントして、Message型の構造体にIDとメッセージ本文を保存するようにします。

// PostMessage関数
messageID++
messages = append(messages, Message{
        ID:   "message-" + strconv.Itoa(messageID),
        Body: message,
})

※この処理はgoroutineセーフではないのですが、簡易的なサンプルということで一旦そこは無視します。

次にmessage本文にIDを振るようにします

<div id="messages">
  {{ range .Messages }}
    <div id="{{ .ID }}" class="message">
      <span class="text">{{ .Body }}</span>
    </div>
  {{ end }}
</div>

次はフォーム送信時に最後のIDを指定するようにします。
まずsubmitコントローラー用にlastMessageIdというターゲットをhidden inputとして作成します。

<input type="hidden" name="lastMessageId" data-submit-target="lastMessageId"/>

次にsubmitする前にメッセージ一覧の最後のIDをlastMessageIdターゲットにセットするアクションを作成します。

// SubmitController
beforeSubmit() {
  const messages = Array.from(document.querySelectorAll(".message"))
  this.lastMessageIdTarget.value = messages.length > 0 ? messages.slice(-1)[0].id : ""
}

そして、beforeSubmitをTurboのsubmitイベントより前に呼び出すアクションを定義します。

<form action="/messages" method="post"
  data-turbo-frame="message-frame"
  data-controller="submit"
  data-action="
    turbo:submit-start@document->submit#beforeSubmit
    turbo:submit-end@document->submit#afterSubmit
  ">

turbo:submit-startはsubmitイベントより前に発生するTurboのカスタムイベントです。

今度はGo側に戻ってTurbo StreamsでlastMessageIdより後のIDのメッセージだけを返す処理を実装します。

func PostMessage(w http.ResponseWriter, r *http.Request) {
        r.ParseForm()
        message := r.FormValue("message")
        lastMessageID := r.FormValue("lastMessageId")
        messageID++
        messages = append(messages, Message{
                ID:   "message-" + strconv.Itoa(messageID),
                Body: message,
        })
        redirectTo := "/"
        if lastMessageID != "" {
                redirectTo = "/?lastMessageId=" + lastMessageID
        }
        http.Redirect(w, r, redirectTo, http.StatusFound)
}

lastMessageIDがフォーム値に入っていた場合はリダイレクト先にもlastMessageIdをURLパラメータとして追加するようにします。

次にリダイクレト先のハンドラを修正します。

func GetIndex(w http.ResponseWriter, r *http.Request) {
        if strings.Contains(r.Header.Get("Accept"), "text/vnd.turbo-stream.html") {
                w.Header().Set("Content-Type", "text/vnd.turbo-stream.html")
                lastMessageID := r.FormValue("lastMessageId")
                messages := messages
                if lastMessageID != "" {
                        for i, message := range messages {
                                if message.ID == lastMessageID {
                                        messages = messages[i+1:]
                                        break
                                }
                        }
                }
                t, _ := template.ParseFS(fs, "template/messages.html")
                t.Execute(w, map[string]any{"Messages": messages})
                return
        }
        t, _ := template.ParseFS(fs, "template/index.html")
        t.Execute(w, map[string]any{"Messages": messages})
}

まず最初にTurboからのリクエストかどうかを判別するため、Turbo StreamのMIMEタイプがAcceptヘッダーに入っているかを判定します。
text/vnd.turbo-stream.htmlが入っている場合はContent-Typeにもtext/vnd.turbo-stream.htmlを入れて部分的なHTML(フラグメント)を返すことでTurbo Stream用のレスポンスに出来ます。

messagesスライスのうちlastMessageIdと同一のIDのmessageが見つかれば、そのメッセージより後ろのメッセージを、そうでなければ全件を返します。

Turbo StreamのレスポンスボディはTurbo Stream用に作る必要があるため別途テンプレートを用意します。

template/message.html

{{ range .Messages }}
<turbo-stream action="append" target="messages">
  <template>
    <div id="{{ .ID }}" class="message">
      <span class="text">{{ .Body }}</span>
    </div>
  </template>
</turbo-stream>
{{ end }}

index.html内のmessages要素とほとんど同じですが以下の2つのタグによって各メッセージ要素が囲まれています。

  • turbo-stream
  • template

turbo-streamタグはturbo-streamでどのようにコンテンツを追加/更新するかを具体的に示すデータです。
turbo-stream内のtemplateタグで囲まれた要素が実際に追加/更新されるコンテンツです。

turbo-streamタグのactionでは要素の追加や置き換えを指定できます。
追加についてもtargetの指定された要素に対して後ろに追加したり、要素内の子要素の前後に追加することを選べたりとかなり柔軟に指定できます。

上記のテンプレートを返す修正でTurbo Stream用の修正は終わりです。
これで、メッセージ追加時の差分更新が可能になりました。

ここまでのコード全体はこちらになります。
https://github.com/minoritea/chat-example/tree/step3

このようにTurbo Streamを使うことでより動的なアプリケーションを組むことが可能になりました。
またその際にはStimulusの補助がとても役に立つことも分かります。

他の例

ここまでで例示したチャット・アプリはTurboとStimulusの紹介のための非常に最低限の実装しか持っていないアプリケーションです。

しかしもう少し具体的な実装を見てみたいという方にこちらの自作アプリケーションも紹介します。

https://github.com/minoritea/chat

こちらも練習目的で作成したチャット・アプリですが普通に使用できるレベルには仕上がっていると思います。
本当はこちらのアプリを例に紹介したかったのですが、思っていたより動作が複雑になっていたため、別途サンプルアプリを用意することになりました。

実際に動いているところを確認したい方はこちらからアクセスできます。※利用にはGithubアカウントが必要です。
https://chat.0pt.jp

またHotwire以外の部分も含めた設計思想についてはこちらの記事で紹介しています。
https://tanstaafl.0pt.jp/posts/2024/01/21/6dee4a958852/

おわりに

長くなりましたがHotwireについて紹介しました。

最近はhtmxなどのHTMLファーストなフロントエンドライブラリが注目されています。
Turboはその中では古参であり有力な選択肢ですし、またStimulusも非常に完成度の高いライブラリです。
実際類似するフレームワーク、ライブラリはhtmx以外にもいくつかの選択肢がありますが、海外のレビュー記事などではそれらの中でもHotwireの完成度が高い、使い勝手が良いという評価が多いように思います。
しかし、いまいち流行っていないように見えるのは、どうしてもRails専用のようなイメージがついて回っているからではないかと思いました。
そのようなイメージを変えたいと思い、言語に限らず使えるライブラリとしてHotwireを使った例を紹介しました。

今回の例ではGoで書きましたが、Goに限らず、サーバサイドでHTMLをレンダリングする言語、フレームワークと組み合わせて自由に使えると思います
(※実際に海外ではLivewireではなくHotwireをPHPと組み合わせるユースケースが多いと聞きましたがどうなんでしょう?自分はPHPに詳しくないので真偽不明ですが)。

現在のフロントエンドの主流はクライアントサイド・レンダリングのSPAですが、サーバサイド・レンダリングやMPA的なアプローチも増えています。
そうした流れの中で、シンプルにフォーム送信だけするようなアプリケーションではJavaScriptによるコンポーネントライブラリに頼ることなくHTMLをそのまま返すだけでよいのでは、と考えている人も少なくないと思います。しかしそのような場合でも現代的なアプリケーションでは多少の動的な要素を無視することはできません。Hotwireはそのような場合に最小限の修正で動的なアプリケーション構築を助けてくれる素敵なライブラリだと思います。

この記事でRails以外でのHotwireの利用例が増えれば幸いです。

Discussion