モーダル

フォーム送信成功と後処理

Hotwireで作成したモーダル中のフォームからリクエストを送信し、成功するところまでを解説します。下記のビデオのものになります。

サーバレスポンスに1秒の遅延を入れています

考えるポイント

modal-crud.webp

interactive-flow-hotwire.webp

  1. formの送信はTurboで行います。レスポンスを返す方法としてはTurbo FramesとTurbo Streamsが考えられますが、今回は裏の画面を更新するだけでなく、トーストも出します。画面の一箇所しか書き換えられないTurbo Framesではなく、複数箇所が更新できるTurbo Streamsを選択します
    1. これ以外にTurbo Driveを使う方法もあります。
      1. Turbo Driveは画面全体を再描画しますので、裏画面を更新し、かつトーストを出すことができます。Morphingを使えば、スクロール位置も維持されます
      2. Turbo Driveが実装としては一番楽になります
      3. ただしモーダルを消すときにアニメーションが出しにくいので今回は採用しません
      4. また(これはTurbo Framesでも同じですが)POST/redirect/GETを使わなければならないため、遅延が大きくなり、レスポンスが悪くなります
    2. 実は「formの送信をTurbo Streamsで行う」というのは正確ではありません。なぜならTurbo Streamsで応答するかどうかはform送信時には決まらないからです。Turbo Streamsでレスポンスするかどうかはあくまでもサーバ側が決めます。詳しくはサーバから見たTurbo FramesとTurbo Streamsの違いで紹介しています。したがって、例えば更新が成功したときはTurbo Driveでredirectして、失敗したときはTurbo Streamsでエラーメッセージを出すなどということも可能です(今回は双方ともTurbo Streamsで処理していますが)
  2. form送信のpending UI(待ちUI)は、Ruby on Railsが昔からデフォルトで提供している機能を使います(UJS: Unobtrusive JavaScript)。Hotwireでも同じ機能が引き継がれています
    1. Turboはformのsubmitterを自動的にdisabledにします 1
    2. 上記のdisabledをCSSで検知し、ボタンの彩度を落とします
    3. ボタンの名前を変更します(「更新する」から「更新中…」へ)
  3. form送信が成功した場合に限り、モーダルを閉じます
    1. form送信結果を受け取り、JavaScriptでモーダルを閉じる必要がありますので、Stimulusを使います
    2. form送信が失敗した場合の処理は別途解説します
  4. form送信が成功した場合はTurbo Streamsを使って、裏画面の書き換えを行います
    1. トーストを表示します
    2. 背景画面のデータを更新します

コード

フォーム

app/views/todos/_form.html.erb
<%= form_with(
      model: todo, id: 'todo-form',
      data: {
        action: "turbo:submit-end->modal-dialog#hideOnSuccess",
        turbo_frame: "_top",
      },
    ) do |form| %>
  <!-- ... -->

  <div>
    <%= form.label :title, class: "block text-sm/6 font-semibold text-gray-900" %>
    <div class="mt-2.5">
      <%= form.text_field :title,
                          class: "block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 sm:text-sm/6" %>
    </div>
  </div>

  <div class="mt-8 flex justify-between">
    <button type="button" class="btn-outline-primary" data-action="click->modal-dialog#hide">キャンセル</button>
    <%= form.submit class: "btn-primary", data: {turbo_submits_with: "更新中..."} %>
  </div>
<% end %>
  • モーダルの中に表示されている<form>の箇所です
  • data-action="turbo:submit-end->modal-dialog#hideOnSuccess"のところは、<form>のレスポンス受信後にStimulus ModalDialogControllerhideOnSuccess()メソッドを呼ぶという指示です
  • formdata-turbo-frame="_top"の箇所はpending UI(待ちUI)の関係で使っています
    • Turboでformを送信すると、form自身、およびそれが関連するturbo-framearia-busy="true" 属性が自動的に付与されます
    • しかし今回はモーダル表示のpending UIでも同様にpending UI(待ちUI)を出しています
    • モーダル表示時のpending UIは表示したくないので、上記のform<turbo-frame id="modal-dialog__frame">と敢えて紐づかいないようにしています。そのために"_top"を指定しています
  • 一番下の<button>(キャンセル)のところは、クリックしたらModalDialogControllerhide()を呼ぶようにしています。モーダルを非表示にするものです
  • form.submitのところは「更新する」ボタンです。これにdata-turbo-submits-with属性がついていて、"更新中…“となっています。これはTurboが用意してくれているoptimistic UI (楽観的UI)です。ボタンをクリックしてformを送信すると、ボタンの名前が自動的に"更新中…"に切り替わってくれます
    • さらにTurboはボタンを自動的にdisabledにもしてくれますので、formの二重送信防止になります
    • 加えてformを送信中は、Turboがform要素にaria-busy属性を自動でつけてくれます。これをCSS擬似セレクタで検知して、待ちUI (pending UI)を追加することもできます

Railsコントローラ

app/controllers/todos_controller.rb
class TodosController < ApplicationController
  # ...

  def update
    respond_to do |format|
      if @todo.update(todo_params)
        flash.now.notice = "Todo was successfully updated."
        format.turbo_stream
      else
        format.turbo_stream { render status: :unprocessable_content}
      end
    end
  end

  # ...

  private

  # ...

    # Only allow a list of trusted parameters through.
    def todo_params
      params.require(:todo).permit(:title)
    end
end
app/views/todos/update.turbo_stream.erb
<% if @todo.errors.any? %>
  <!-- ... -->
<% else %>
  <%= turbo_stream.replace dom_id(@todo) do %>
    <%= render @todo, highlight: true %>
  <% end %>

  <%= turbo_stream.replace "global-notification" do %>
    <%= render "global_notification" %>
  <% end %>
<% end %>
  • updateのActionでリクエストを受け取り、レスポンスを返します
  • ここではflash.nowにトーストのメッセージをセットし、Turbo Streamのレスポンスを返しています
    • RailsでMPAやTurbo Driveでupdateをするときは、flashを使うことがほとんどです。一方、ここではflash.nowを使っています。flashはリダイレクト後にトーストを表示するときに使いますので、「次回のリクエスト」で使いたい時に使用します
    • それに対してflash.nowは「現在のリクエスト」が対象です。すぐに使いたい時に使います
    • POST/Redirect/GETのパターンを使う時はredirectを挟むのでflashを、今回のようにPOSTに対して直接レスポンスを返している場合はflash.nowと使い分ける形になります
    • なおトーストを表示する方法については、別途解説しています
  • 今はまず正常系だけ見ていますので、Turbo StreamのERBテンプレートでは、if ... elseelseの方だけみます
    • Turbo Streamのturbo_stream.replace dom_id(@todo)でデータが更新された行をreplaceで置換しています。背景画面であるapp/views/todos/index.html.erbでは、各行をapp/views/todos/_todo.html.erb partialでレンダリングしていますが、ここでは@todoに該当する行だけを置換しています
    • Turbo Streamのturbo_stream.replace "global-notification"ではトーストを表示します。トーストの内容はglobal_notification partialからとっています。なおトーストをよく使用するのであれば、丸ごとhelperにしてしまった方が便利かもしれません(下のglobal_notification_stream helperのコードを参照)
app/helpers/global_notification_helper.rb
module GlobalNotificationHelper
  def global_notification_stream
    turbo_stream.replace "global-notification" do
      render "global_notification"
    end
  end
end

モーダルを隠す

app/javascript/controllers/modal_dialog_controller.js
import {Controller} from "@hotwired/stimulus"

// Connects to data-controller="modal-dialog"
export default class extends Controller {
  static values = {
    shown: {type: Boolean, default: false},
    page: String
  }

  // ...

  hide(event) {
    this.shownValue = false
  }

  // ...

  hideOnSuccess(event) {
    if (!event.detail.success) {
      return
    }

    this.hide(event)
  }
  // ...
}
  • 上述した通り、turbo:submit-endイベントに対してhideOnSuccess(event)メソッドを実行させるように<form>data-action属性を記述しました
  • hideOnSuccess(event)メソッドはステータスがsuccessだったかどうかを確認し、そうだった場合はhide(event)メソッドを呼び出してモーダルを隠します
    • 失敗だった場合、モーダルはそのまま開いておきます(もう一度ユーザに入力し直してもらいたいため)

まとめ

  • フォーム送信をRailsのcontrollerで受け取り、成功した場合にレスポンスを返すところをやりました
    • Turbo Streamsでモーダルの裏の画面の更新をしました
    • トーストの内容も一緒にTurbo Streamsで更新しました
  • 同時にturbo:submit-endのイベントを検知して、モーダルを非表示にしました。特にレスポンスが成功した時だけ非表示にするようにしています。バリデーションエラーの時はモーダルを表示したままにしました
  • これに加えて、formdisableして二重送信防止をしたり、軽いpending UIも実装しました

高いレベルのUI/UXを実現するためには、細かいことをたくさん実施する必要があります。その分、どうしても処理が複雑になることは避けられません。ただしHotwireを使うと、その一つ一つが少ないコードで実現できますので、UI/UXが複雑になってもうまく対応できます。


  1. 私はRails出身ということもあり、form送信時にdisabledにするのは二重送信防止策として一般的だと思いましたが、Next.jsなどではやっておらず、MDNなどでも言及していないようです。ただし実際に試してみるとNext.jsは二重送信をしてしまいますし、二重送信対策としてのdisabledはウェブ上でも広く推奨されています。なるべくならばやった方が間違いないでしょう 

  2. リクエストが成功したかどうかはサーバが決定することです。そしてこれをブラウザに表示しなければなりませんが、どのようにメッセージを伝播するべきかは難しい問題です。Hotwireの場合は大きく2つの選択肢があります。1つ目はレスポンスのbodyであるTurbo Streamを使うことです。この場合はサーバが「モーダルのHTML要素を変えろ!」と指示します。2つ目はレスポンスのstatusで200系や400系を返し、ブラウザ側でstatusを読み取り、ブラウザ側が自ら「モーダルを消すぞ!」と判断する形です。開発者によって、どのやり方を選択するかが異なります。なお、ReactだとJSON APIしかないので、必然的に後者の形になります。私はモーダルの表示・非表示はあくまでもブラウザが管理することであり、なるべくならサーバは関わるべきではないと思っていますので、後者を採用しています。