考える手順

TurboとStimulus: どっちを使う?

Hotwireはいろんな技術から構成されている

Hotwireは大きくTurbo, Stimulus, Nativeの技術から構成されています。そしてTurboはさらにTurbo Drive, Turbo Frames, Turbo Streamsに分かれています。Nativeはモバイルアプリを作るためのツールなので使い分けが明確です。一方でTurboとStimulusのそれぞれの役割と使い分け方は迷うかもしれません。

ここではTurboとStimulusの使い分けについて解説します。

hotwire-component-structure.webp

そもそもインタラクティブなUIの仕組みは?

TurboとStimulusの役割を知るには、インタラクティブUIの要件を定義する必要があります。明確な定義はありませんが、インタラクティブなUIは一般的に以下の動作をするものと言えます。

  1. ユーザが何らかの操作(イベント)を受け付けます。キーの入力、ボタンのクリック、ドラッグドロップなどが想定されます
  2. ページ遷移をするのではなく、(少なくとも見かけ上は)同じページで応答を表示します(画面更新
    1. Google Mapsであれば、別の地点の地図が表示されます
    2. モーダルダイアログが表示される、メニューが降りてくる、サイドパネルに情報が表示されるなどが考えられます
    3. データがサーバ上にあるの場合は、ここでサーバへのリクエスト送信とレスポンスの受信を行います

つまり 「イベント ==> 画面更新」 までを同じページの中で処理するのがインタラクティブUIの要件と言えます

TurboとStimulusによるインタラクティブなUI

こう考えると、「イベント ==> 画面更新」 までの流れの中でTurboとStimulusの役割を考えることができます。

interactive-flow-hotwire.webp

  • 一番上の黒の箇所は、ブラウザがネイティブに持っているインタラクティブUIの流れです。例えばチェックボックスをクリックするとチェックが入るものだとか、テキストフィールドにフォーカスした時にキーを押すと、対応する文字が表示されるとかです。イベントを受け取るところから画面に反映するところまで、ブラウザがネイティブに処理してくれます
  • 二番目の緑の箇所はStimulusを使用しています。Stimulusはブラウザのイベント処理を整理し、再利用可能にする仕組みです。ブラウザのネイティブな処理とは異なるものを実施したいときにStimulusを使います。ここではStimulus Controllerがactionでイベントをハンドルし、さらに画面のtargetで指定した箇所を更新します
  • 三番目の青の箇所はTurboを使用したものです。ここからはサーバにリクエストを送信して、レスポンスを処理するタイプのインタラクティブUIの話になります。Turboは<a>タグのclickイベント、<form>タグのsubmitイベントなど、ブラウザのネイティブなイベントを真似、これらのイベントを自動的に処理します。サーバからレスポンスを受けると、Turbo Drive, Turbo Frames, Turbo Streamsのそれぞれ決まった方法に従って、レスポンス内容を画面に反映します。Turbo Driveの場合は画面遷移、Turbo Framesの場合は画面の一箇所の部分置換、Turbo Streamsの場合はレスポンス次第で複数箇所の部分置換や部分追加、削除を行います
  • 四番目の箇所は、StimulusとTurboを複合的に使ったものになります。
    • <a>タグのclickイベント、<form>タグのsubmitイベント以外のイベントを処理したい場合は、Stimulusでイベントをハンドルして、サーバにTurboのリクエストを送信します。例えばライブ検索であれば<input>タグのinputイベントに反応したり、キーボードショートカットであればkeydownイベントに反応できるようになります。Turboのリクエストを送信することで、上述したTurbo Drive, Turbo Frames, Turbo Streamsによる自動処理が行われます。
    • Turboのリクエストを受信すると、上述したTurbo Drive, Turbo Frames, Turbo Streamsによる自動処理が行われます。この自動処理をカスタマイズしたい場合は、Turbo送受信時にカスタムイベントが発火しますので、これを受け取って処理します。一般にイベントはStimulus Controllerで拾うのが便利でしょう。例えばモーダルダイアログ中の<form>からPOSTのリクエストを受取、成功した場合にモーダルダイアログを自動的に閉じるなどの処理が可能になります。

このように、Hotwireではブラウザネイティブ、Stimulus、Turboのイベント処理を組み合わせて、望み通りのUI/UXを実現します。サーバとの通信が不要な場合はStimulusだけで処理し、サーバとの通信が必要な場合は主にTurboを使いながら、補佐的にStimulusを使います。

ポイント: Hotwireはイベントを使うのでasync awaitが不要

HotwireのJavaScriptを書いていると、async awaitをほとんど書かないことに気づきます。それどころか、Hotwireで使っているJavaScriptは入門者レベルの簡素なものばかりです

この大きな要因はHotwireがJavaScriptイベントベースだからです。Stimulusがアクションを受け取るところ、Turboがレスポンスに対して後処理をする箇所はすべてイベントベースです。イベントはすべて非同期(async)に実行されます。Stimulus Controllerの中でasyncなコードを書かなくても、JavaScriptがそれをすべて非同期処理してくれるのです。

一方でReactは主にイベントではなくコールバックのパラダイムで記述します。ボタンをクリックした時のイベントハンドラはコールバック(関数へのレファランス)として親コンポーネントから子コンポーネントに渡されます。useEffectの中身もコールバックとして記述します。

イベントベースの記述とコールバック中心の記述は本質的な違いはないのですが、人間にとってのわかりやすさには大きな違いはあります。特に初心者や難しいJavaScriptをあまり書かない人にとってはイベントベースの方がわかりやすいです。

(参考)ReactによるインタラクティブなUI

上記ではモーダルダイアログのUIを想定しています。そしてモーダルの中身はサーバから取得するものとします。

interactive-flow-react.webp

  • Reactの大きな特徴はステートです。必ずステートを更新してから、それの結果として画面が更新されるようにします
  • イベントハンドラはまずステートを更新します。ステートを更新すると自動的に再レンダリングして、画面が更新されます
  • 画面の更新をした結果、新しいモーダルダイアログコンポーネントが表示されます
  • モーダルダイアログコンポーネントの中のuseEffect()が起動し、fetch()でサーバにリクエストを送信します
  • レスポンスを受信したら、受信内容をステートにセットします。ステートが更新されましたので、自動的にモーダルダイアログの部分の画面が更新されます

このようにReactでは必ず最初にステートを更新し、ステートから自動的に画面を更新します。インタラクティブUIの流れを敢えて一種類に絞っています。一種類しかないのでわかりやすい反面、自動処理をしてくれる箇所がなく、すべてカスタムで用意することになります。

サーバ通信が必要かどうかの判断は意外に難しい

上述のように、Turboはサーバとの通信が必要な時だけに使用します。ブラウザがすでに持っているデータだけで完了する場合はTurboは不要で、Stimulusだけで十分です。しかしこの判断は意外と単純ではありません。下記のことを考慮して最終判断をする必要があります。

  • サーバからの最新情報が必要か?: サーバから常に最新の情報が欲しいのであれば、Turboを使ってサーバと通信するしかありません。逆に、例えばプロフィール情報であればユーザ自身しか変更する人はいませんので、必ずしも最新である必要はありません(自ら更新したとき以来の最新であれば十分です)。その場合はStimulusで十分です
  • HTMLは更新するか?: Stimulusでは大きくHTMLを書き換えるのは一般的に避けます。CSSクラスの変更やデータの変更ぐらいは問題ありませんが、新しいコンポーネントを表示するような変更はなるべくやりません。したがって大きくHTMLを書き換える場合はTurboを使うことが多いです
  • レスポンスはなるべく早くしたいか?: 例えば「いいね」ボタンでオン・オフ状態を切り替えたい時サーバからのレスポンスを待っているともっさりしたUI/UXになります。この場合はブラウザネイティブもしくはStimulusのイベント処理で楽観的UI(Optimistic UI)を作ることを検討します。逆にブログ記事を書く編集画面に遷移する場合は、多少レスポンスが遅くても問題になりません。状況に応じて楽観的UIを使うべきかを判断します
  • 実装は簡単か?: TurboとStimulusの双方で実装できるものの、Turboの方が簡単に実装できることはしばしばあります。この場合はまず最初にTurboで実装することが多いです。レスポンスを早くしたいと考えた時点で、Stimulusもしくはブラウザネイティブに切り替えるのが良いでしょう

私のやり方: 時期尚早な最適化を避ける

上述のように、TurboとStimulusのどれを使うかは単純ではありません。開発の初期から考えすぎないことも重要です。私は通常、下記の順番で開発しています。

  1. 本当に適切なUIデザインかどうかを批判的に確認します。UI/UXで著名なヤコブ・ニールセン氏が提唱するUXの法則は「ユーザーはほとんどの時間を他のサイトで過ごしており、あなたのサイトが、すでに知っている他のすべてのサイトと同じように機能することを好む」というものです。つまり実装が大変で、あまり見かけないUIは、そもそもが悪いUIの可能性が高いのです。よく見かけるUIかどうかを最初に確認することが大切です。これを怠ると、実装が大変でかつ使いにくいUI/UXになってしまいます
  2. 最初は実装のしやすさを重視します。通常、一番実装しやすいのはMPAですので、画面の部分置換をしないMPAで実装します。Turbo Driveのおかげで、MPAとして作ってもSPAのサクサク感がありますので、これだけで十分ということがほとんどです
  3. 次にTurbo FramesやTurbo Streamsを使って、画面の部分置換を少しずつ導入します。通常は数行の小さな変更で済みますが、コードは僅かでも間違いなく複雑化していますので、なるべくわかりやすさを保つように意識します
  4. 最後にUI/UXの不足分をStimulusで補います
  5. レスポンスの速さが問題になる場合はTurboを減らし、ブラウザネイティブもしくはStimulusのみで実装できないかを検討します。やはりコードは複雑化しますので、メンテナンス性も含めて本当にこれを実施する必要性を確認するべきです

インタラクティブUI/UXは最適化と捉えるのが良いと思います。時期尚早な最適化を避ける意味でもMPAから出発し、段階的にインタラクティブな要素を追加するのが良いのではないかと思います。