✂️

htmx とはなんですか?

2023/11/25に公開

htmx は Ajax を簡単に行えるようにする JavaScript のライブラリです。

基本的に、サーバーからのレスポンスをそのまま指定の DOM に描画するだけの、シンプルな機能提供となっています。

HTML 作成の責務がサーバーにあるため、HTML 描画のためのヘルパーライブラリが既に多くあるフレームワークに Ajax を持ち込みたい時に活躍するのではないでしょうか。

というわけで実際ありそうな用法を試してみたのでつらつらとメモります。

ごく単純な例

ごく単純には、以下のような HTML を用意した上で htmx を読み込むと

<button
  hx-get="/count"
  hx-trigger="click"
  hx-target="#count"
  hx-swap="outerHTML"
>count</button>

<div id=count>
  <!-- /count からのレスポンスがそのまま入る -->
</div>
  • button をクリックした時に
  • GET /count のレスポンスで
  • #count 丸ごと入れ替える

ようになります。

ページネーションを htmx 化してみる

画面の一部を再描画すればいいページと言えば一覧ページなので、ページネーションを htmx 化する例を書きます。Rails + kaminari のベタな構成と考えてください。

再描画部分を用意する

ページが進むごとに変化する部分を含めます。

一覧部分と、現在どのページにいるかが分かるようにするため、ページリンク部分も含めます。

_index_body.html.erb
<div id="users-index">
  <table class="table">
    <thead>
      <tr>
        <th>Name</th>
        <th>Sex</th>
      </tr>
    </thead>
    <tbody>
      <% @user_list.each do |user| %>
        <tr>
          <td><%= user.name %></td>
          <td><%= user.sex %></td>
        </tr>
      <% end %>
    </tbody>
  </table>
  <%= paginate @user_list %>
</div>

ページを用意する

単に描画するだけですね。

index.html.erb
<h1>Users</h1>

<%= link_to 'New User', new_user_path %>

<%= render partial: "index_body" %>

再描画部分を返す部分を用意する

初期描画時やリロード時は完全な HTML を返しつつ、htmx からのリクエストでは再描画部分のみを返さなければなりませんが、使うデータは同じなので params の内容を見て切り替えるのも一つの手でしょう。

class UsersController < ApplicationController
  def index
    @user_list = User.
      page(params[:page]).
      per(10)

    if htmx?
      render partial: "index_body"
    else
      render :index
    end
  end

  private

  def htmx?
    ActiveRecord::Type::Boolean.new.cast(params[:htmx])
  end
end

htmx 部分を用意する

ページ変化のトリガーはページリンク部分なので、ページリンク部分に htmx 用の属性を仕込みます。first や last などは本題でないので省略しています。

_paginator.html.erb
<%= paginator.render do -%>
  <nav class="pagination" role="navigation" aria-label="pager">
    <% each_page do |page| -%>
      <% if page.display_tag? -%>
         <span class="page<%= ' current' if page.current? %>">
          <%= link_to_unless(
                page.current?,
                page,
                url_for(page: page.number),
                'hx-get': url_for(page: page.number, htmx: true),
                'hx-push-url': url_for(page: page.number),
                'hx-target': '#users-index',
                'hx-swap': 'outerHTML',
              ) %>
        </span>
      <% elsif !page.was_truncated? -%>
        <%= gap_tag %>
      <% end -%>
    <% end -%>
  </nav>
<% end -%>

hx-push-url という属性はページ遷移はさせないが URL は変更したい時に使います。これにより、リロードしても同じページがクエリ文字列で指定されますし、ブラウザバック/フォワードにも対応するようになります。

なお hx-get 以外のパスには htmx=true が必要ないので、含まれないようになっています。

これで一覧の単純なページネーションは Ajax 化できました。便利ですね。

ページネーションの一覧部分とページリンクを別々に描画する

上記の例では #index-body にすべてを詰め込みましたが、一覧とページリンクの間に様々なコンテンツがあったりして、一緒にレスポンスを返すのがリーズナブルではない場合もあるでしょう。

そんな場合は別々の位置を再描画する multi-swap という拡張機能が使えます。

拡張機能を読み込む

本体に加えて以下を読み込みます。

<script src="https://unpkg.com/htmx.org/dist/ext/multi-swap.js"></script>

再描画部分を用意する

別々の位置に描画するため、別々のパーツとして用意します。

それぞれ別の id を指定していることに注目してください。

_multi_swap_index_body.html.erb
<div id="users-index">
  <table class="table">
    <thead>
      <tr>
        <th>Name</th>
        <th>Sex</th>
      </tr>
    </thead>
    <tbody>
      <% @user_list.each do |user| %>
        <tr>
          <td><%= user.name %></td>
          <td><%= user.sex %></td>
        </tr>
      <% end %>
    </tbody>
  </table>
  <%= paginate @user_list %>
</div>
_multi_swap_index_pager.html.erb
<div id="index-pager">
  <%= paginate @user_list, views_prefix: :multi_swap %>
</div>

ページを用意する

それぞれのパーツを離れた場所で描画します。

multi_swap_index.html.erb
<h1>Users</h1>

<%= link_to 'New User', new_user_path %>

<div hx-ext="multi-swap">
  <%= render partial: "multi_swap_index_body" %>
  <div>なんか他のコンテンツ</div>
  <%= render partial: "multi_swap_index_pager" %>
</div>

拡張機能を有効にするため hx-ext="multi-swap" という属性が増えていることにも注意してください。

再描画部分を返す部分を用意する

基本同じですが、再描画用のレスポンスは再描画部分両方が含まれたレスポンスとなります。

_multi_swap_index_fragments.html.erb
<%= render partial: "index_body" %>
<%= render partial: "index_pager" %>
class UsersController < ApplicationController
  def multi_swap_index
    @user_list = User.
      page(params[:page]).
      per(10)

    if htmx?
      render partial: "multi_swap_index_fragments"
    else
      render :multi_swap_index
    end
  end

  private

  def htmx?
    ActiveRecord::Type::Boolean.new.cast(params[:htmx])
  end
end

htmx 部分を用意する

hx-target を消し hx-swap'hx-swap': 'multi:#index-body,#index-pager' というように変更しています。

これで今回分割したファイルにそれぞれ入っている #index-body#index-pager がターゲットとなります。

レスポンス中のどの DOM がどのターゲット DOM に入るかは、この時に指定したセレクター決定されるようです。

_paginator.html.erb
<%= paginator.render do -%>
  <nav class="pagination" role="navigation" aria-label="pager">
    <% each_page do |page| -%>
      <% if page.display_tag? -%>
         <span class="page<%= ' current' if page.current? %>">
          <%= link_to_unless(
                page.current?,
                page,
                url_for(page: page.number),
                'hx-push-url': url_for(page: page.number),
                'hx-get': url_for(page: page.number, htmx: true),
                'hx-swap': 'multi:#index-body,#index-pager',
              ) %>
        </span>
      <% elsif !page.was_truncated? -%>
        <%= gap_tag %>
      <% end -%>
    <% end -%>
  </nav>
<% end -%>

これでどんな飛び地構成でも Ajax で再描画できるようになりました。

フォームをやる

一覧ページの他に遷移なしに再描画できれば嬉しいページと言えば、フォームのエラー時でしょうか。というわけでフォームもやります。

拡張機能を読み込む

実は htmx はデフォルトでは正常系のレスポンスでしか再描画してくれません。しかしフォームの POST がエラーになった時は 4xx を返したいですよね?心情的に。

というわけで拡張機能を使います。

<script src="https://unpkg.com/htmx.org/dist/ext/response-targets.js"></script>

再描画部分を用意する

よくあるフォームですが form 部に htmx 用の属性が追加されていることに注目してください。

大体見ればわかると思いますが hx-target ではなく hx-target-error とすることにより、異常系のレスポンスの時に再描画を行うようになります。

hx-target-error は拡張機能なので hx-ext="response-targets" を忘れないようにしてください。

_form.html.erb
<div id="new-user-form" hx-ext="response-targets">
  <%= form_with(
        model: @user,
        url: users_path,
        local: false,
        html: {
          'hx-post': users_path,
          'hx-target-error': '#new-user-form',
          'hx-swap': 'outerHTML',
        }
      ) do |form| %>
    <% if @user.errors.any? %>
      <div id="error_explanation">
        <ul>
          <% @user.errors.full_messages.each do |message| %>
            <li><%= message %></li>
          <% end %>
        </ul>
      </div>
    <% end %>

    <div class="field">
      <%= form.label :name %><br>
      <%= form.text_field :name %>
    </div>

    <div class="field">
      <%= form.label :sex %><br>
      <% User.sexes.keys.each do |sex| %>
        <%= form.radio_button :sex, sex %>
        <%= form.label "sex_#{sex}", sex.titleize %><br>
      <% end %>
    </div>

    <div class="actions">
      <%= form.submit %>
    </div>
  <% end %>
</div>

実際はなんらかのヘルパーを使って項目ごとにエラーを描画することになると思いますが、再描画用の HTML もサーバーが用意するので同じ機能が使えて便利ですね。

ページを用意する

特に他の要素はないので省略

再描画部分を返す部分を用意する

保存失敗時には再描画用の HTML を返すようにします。

class UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.new(params_permit)

    if @user.save
      redirect_to users_path
    else
      render partial: "form", status: :bad_request
    end
  end

  private

  def params_permit
    params.
      require(:user).
      permit(
        :name,
        :sex,
      )
  end
end

これで保存成功時は一覧に戻り、失敗時は htmx でエラー表示を含んだ感じに再描画するようになりました。

まとめ

X を流し読みしていた時に他のプロジェクトで「このプロジェクトは htmx を使ってるんだよ〜〜」と売り文句のように書いてあったので試してみましたが、なかなか良さそうで普通にアリだと思いました。

Rails の Turbo があまり好きではないので、明示的に局所的にいい感じにしたりするのに使ったり、あるいはもうたっぷりと作ってあるプロジェクトを一部いい感じにするのに便利なんじゃないかなと思いました。

ハートレイルズ

Discussion