Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Rails 7 + Hotwireで実用的なSPAを作ってみた

こんにちは、サーバーグループの市川です。モバイルクライアントグループに所属していましたが、バックエンドの開発を担当することが多くなったので、サーバーグループ異動にしました。

最近、Studyplusブックというサービスをリリースしました。Studyplusアプリ内で、200冊以上の参考書が読み放題で使えるサービスです。

このサービスの開発にあたって、社内向けの管理画面を新規に構築したのですが、Rails 7から標準でインストールされるHotwireを利用することにしました。

今回はHotwireを実際に利用する上で、どういったUIを作ればいいか試作しましたので、その話を書きます。

作った画面

社内向けの管理画面で使うので、以下のような基本的な操作ができる画面を試作しました。

  • 一覧・追加・編集・削除をページ移動せずできる (SPAのような操作性を実現)
  • 一覧はページネーションできる (kaminariを使用)

実際に動かしたデモが以下です。

書いたソースコード

ソースコードは以下のGitHubリポジトリに公開していますので、興味があったら見てみてください!

github.com

このコードは、Rails 7のScaffoldのコードをベースに、それを改良することで実現しています。 なるべく細かい単位でコミットしたので、どういった意図でどういう変更をしているのかがわかりやすいようになっています。 このブログでは、このリポジトリの内容に沿って解説しています。

使った技術

  • Hotwireといっても、今回Stimulusは使わず、Turboのみで実現しています。Hotwireは、JavaScriptをあまり使用せずに、HTMLベースで機能を実現するアプローチなので、まずはTurboをうまく活用して実用的なUIを実現する方法を検討しました。
  • CSSフレームワークにはTailwind CSSを使っています。これは tailwindcss-rails gemを使うことで、Node.jsなしで導入できるため採用しました。ただし、好みによって、他のCSSフレームワークでも特に問題はないと思います。

解説

  • 実際に作ったUIを題材にTurboがどうやって動いているか、どう使うといいのかを紹介します。Turboの詳細については公式サイトにも詳しく書いてあるので、合わせて読むことをオススメします。
  • なお、ブログ内のコードは、わかりやすくするために装飾のためのclassなどの定義は消しています。実際のコードはリポジトリで確認してください。

Turboを理解する

Turboがどのように動いているかを理解すると、ブラウザで何が起きているのかを正しく認識するのに役立ちます。 TurboはJavaScriptで書かれたライブラリなので、ブラウザ上で動作しています。

  • ブラウザ上のリンクやボタンをクリックすると、Turboはそのリクエストを監視しており、ブラウザの代わりにFetch APIを使ってサーバにリクエストを送ります。
  • サーバは従来どおりHTMLを返しますが、Turboはレスポンスの内容から必要な情報を更新します。

Turboはサーバから送信するHTMLよって、ブラウザの動作を制御する仕組みになっています。 また、画面全体をリロードしないので、高速に画面を切り替えることができます。

Turbo Frameでフレーム分割する

Turboは <turbo-frame>タグを使って、擬似的なフレームを作ることができます。 Turbo Frameでページ内の情報をどう分割するかを決めるのはとても重要です。今回は以下のようにフレームを分割しました。

水色の部分がフレームで、各フレームには異なるidを指定します。

各フレームには以下の情報を表示します。

  • new_article: Newボタン、追加フォーム
  • articles: articleのリスト、ページネーションのリンク
  • article_<id>: 各articleの表示、編集フォーム

Turboは、フレーム内のリンクやボタンが押されると、レスポンスに含まれる同じIDのフレームで、そのフレームを更新します。

追加フォーム (Turbo Frame)

ここからは、実際にNewボタンを押して、追加フォームに切り替わるコードを解説します。 コード上でやることはとても簡単で、フレームに表示したい内容(Newボタンや追加フォーム)を同じIDの turbo_frame_tag で囲うだけです。

まず、Newボタンを turbo_frame_tag で囲う。

# app/views/articles/_new_button.html.erb
<%= turbo_frame_tag "new_article" do %>
    <%= link_to 'New', new_article_path %>
<% end %>

次に、追加フォームを turbo_frame_tag で囲う。(引数に@articleを渡すと、id='new_article' に自動で変換してくれます)

# app/views/articles/new.html.erb
<%= turbo_frame_tag @article do %>
  <%= render "form", article: @article %>
  <%= link_to 'Back', articles_path %>
<% end %>

最後に、一覧ページにNewボタンを設定すれば完成です。

# app/views/articles/index.html.erb
<%= render "new_button" %>

図にすると以下のような動きをしています。

追加処理 (Turbo Stream)

追加フォームが表示できたので、次に追加後の表示について解説します。 追加フォームをNewボタンに戻すだけなら、Turbo Frameで対応できますが、新しく追加した情報をリストに表示したい場合、Turbo Streamを使う必要があります。

追加後に、以下のような特殊なレスポンス (Turbo Stream) を返すことで、Turbo側で更新する内容を指定できます。

# app/views/articles/create.turbo_stream.erb
<%= turbo_stream.prepend 'articles' do %>
  <%= render @article %>
<% end %>
<%= turbo_stream.replace 'new_article' do %>
  <%= render "new_button" %>
<% end %>
  • turbo_stream.prepend 'articles' は、idがarticlesの要素に render @article の内容を追加 (prepend) するアクションです。
  • turbo_stream.replace 'new_article' は、idがnew_articlesの要素を render "new_button" の内容で置き換える (replace) アクションです。

図にすると以下のような動きをしています。

このように、Turbo Streamを使うと、フレーム外に作用させたり、複数箇所を一度に更新したりできます。 Turbo Streamのアクションは全部で7種類あります。詳しくは公式サイトを確認してください。

追加フォーム対応時のコード

編集フォームと更新

編集フォームと更新後の表示は、フレーム内を編集フォームとarticleの表示で切り替えるだけなので、Turbo Frameだけで実現できます。 コード上は turbo_frame_tag で囲うだけで対応できます。引数にarticleオブジェクトを渡すと自動的に id='article_1' という属性が付くので、同じIDのフレームになります。

# app/views/articles/_article.html.erb
<%= turbo_frame_tag article do %>
  <%= article.title %>

  <%= article.content %>

  <%= link_to 'Edit', edit_article_path(article) %>
<% end %>
# app/views/articles/edit.html.erb
<%= turbo_frame_tag @article do %>
  <%= render "form", article: @article %>

  <%= link_to "Back to articles", articles_path %>
<% end %>

編集フォーム対応時のコード

削除

データを削除する処理は、何も変更を加えなくてもそのままでも動きます。 ただし、削除後に articles_url へリダイレクトすると無駄にリストを再取得してしてしまうので、Turbo Streamを使って、削除した項目だけを消す処理に変更しました。 Turbo Streamは、以下のようにコントローラにも定義できます。

# app/controllers/articles_controller.rb
def destroy
  @article.destroy
  render turbo_stream: turbo_stream.remove(@article)
end

削除をTurbo Streamに変更時のコード

また、確認ダイアログを追加したい場合は、削除ボタンに form: { data: { turbo_confirm: "Are you sure?" } }を追加するだけで実装できます。

<%= button_to 'Destroy', article_path(@article), method: :delete, form: { data: { turbo_confirm: "Are you sure?" } } %>

ダイアログの追加時のコード

詳細表示

項目が多いテーブルの場合、一覧には一部の項目だけを表示したいケースがあります。 そういう場合は、詳細表示ができると便利だと思ったので、Turbo Frameを使って対応してみました。

通常、詳細表示はshowアクションがそれに相当するのですが、リストの一部を表示するのに使っています。今回は /show?detail=true の場合、詳細表示されるようにしました。

コントローラは、paramsで使うビューを切り替えます。

# app/controllers/articles_controller.rb
def show
  render :show_detail if params[:detail]
end

詳細用のビューも turbo_frame_tag で囲う。

# app/views/articles/show_detail.html.erb
<%= turbo_frame_tag @article do %>
    <%= render 'article_detail', article: @article %>
<% end %>

詳細表示の対応時のコード

ページネーション

業務ではページネーションにしたいケースは多くあります。Turboではページネーションへの対応も簡単に実現できます。 まず、kaminariのgemを追加した後に、コントローラ側でページごとに情報を取得できるようにします。

# app/controllers/articles_controller.rb
def index
  @articles = Article.order(id: :desc).page(params[:page]).per(3)
end

次に、ページを切り替えるリンク <%= paginate @articles %> を追記して、リスト表示全体を turbo_frame_tag 'articles' で囲えば完成です。

# app/views/articles/index.html.erb
<%= turbo_frame_tag 'articles' do %>
  <%= render @articles %>
  <%= paginate @articles %>
<% end %>

これで、ページを切り替えると、articles フレームだけが更新されるようになります。

kaminari追加時のコードTurbo Frame変更時のコード

まとめ

Turboをうまく活用することで、JavaScriptを書かずに、Railsのテンプレートエンジンを利用したまま、SPAのような操作性を実現できました。

Hotwireを適切に利用するには、以下の順番で考えていくとよかったです。

  • まずはTurbo Frameを使って、画面をコンポーネントごとに分割する。
    • 最終的には各ビューのトップレベルは turbo-frame になりました。
  • フレーム外や複数の要素を変更した場合にはTurbo Streamを使う。これによって、柔軟な画面更新が可能になります。
  • HTMLだけでは表現できない部分については、Stimulusを使ってJavaScriptで拡張する。

また、Hotwireは既存のHTMLをベースにしているので、よく使う一部のページから改善していくといった使い方ができるのもとてもいいと感じました。