HTMLを拡張し⁠JSなしで動的UIを作る「htmx」

htmxは、HTMLの属性を追加するだけで、インクリメンタル検索やインライン編集をはじめとするさまざまな動的なUIUser Interfaceを実現できるJavaScriptライブラリです。SPASingle Page Applicationフレームワークのような大がかりなビルド環境を用意することなく、すでにあるサーバーサイドアプリケーションに段階的に導入できる点も大きな魅力です。

本記事では、htmxの基本的なしくみや使用方法を紹介したのち、架空のタスク管理アプリケーションを題材に、htmxで実現できるさまざまなインタラクションを紹介します。

htmxの概要

HTMLを拡張するアプローチ

htmxの基本的なアイデアは、HTMLの属性を使ってサーバーとの通信およびDOM更新を宣言的に記述するというものです。

たとえば、ボタンがクリックされたらサーバーからデータを取得し、画面の一部を更新したいとします。近年主流のJSON APIを使ったアプローチでは、JavaScriptのfetch()でリクエストを行い、返ってくるJSONデータを元にDOMを構築する必要がありました。

htmxなら、以下のようにいくつかのHTML属性を書くことで、これを実現します。

<button hx-get="/hello" hx-target="#result">
  読み込み
</button>
<div id="result">ここに結果が表示されます</div>
htmxの基本動作のアニメーション。「読み込み」ボタンをクリックするとボタンの下部に「こんにちは! /helloです!」とテキストが表示される。

hx-getはクリック時にGETリクエストを送信し、hx-targetはレスポンスのHTMLを挿入する先を指定する属性です。サーバーからは以下のようなHTML断片が返されています。

<p>こんにちは! <code><b>/hello</b></code> です!</p>

このように、HTML属性を使用し、⁠どこにリクエストするか⁠⁠、返ってきたHTML断片を「どの位置に」⁠どのように差し替えるか」を指定するのがhtmxの本質です。

従来のアプローチとの違い

ReactやVue.jsなどのSPAフレームワークでは、サーバーはJSON形式で応答し、クライアント側のJavaScriptがそのデータを受け取ってUIをレンダリングします。コンポーネントを組み合わせることで複雑な動的UIを自然に構築できる反面、ビルドツールや状態管理ライブラリなど、フロントエンド固有のツールチェインが必要になります。

htmxの最大の特徴は、サーバーがJSONではなくHTMLの断片を返すということです。サーバーがHTMLを返すという点は従来のウェブアプリケーションと共通しますが、htmxが要求するのはページ全体ではなく必要な部分のHTMLだけである点がポイントです。

このしくみにより、以下のようなメリットが得られます。

複雑なビルド環境が不要
<script>タグで読み込むだけで使い始められます。
JavaScriptをほとんど書かずに済む
HTML属性で動的UIを宣言できます。
開発量を劇的に減らせる
フロントエンドのためのJSON APIは不要です。
サーバーサイドの技術を選ばない
Rails、Laravel、Djangoなど、HTMLを返せるフレームワークならどれでも使えます。

インストール方法

htmxの導入は非常に手軽です。最も簡単な方法は、CDNContents Delivery Networkから読み込む方法です。

<script src="https://unpkg.com/htmx.org@2"></script>

これだけでhtmxが有効になります。CDNに依存したくない場合は、ライブラリファイルを直接ダウンロードしてプロジェクトに配置する方法もあります。

curl -o htmx.min.js https://raw.githubusercontent.com/bigskysoftware/htmx/refs/tags/v2.0.8/dist/htmx.min.js

取得したhtmx.min.jsをプロジェクトの静的ファイルディレクトリに配置し、<script>タグで読み込めばOKです。

htmxで実現するさまざまなインタラクション

「HTMLの属性を足すだけ」と聞くと、できることは限られるのでは? と思われるかもしれません。実際は、意外なほど多くのことがhtmxだけで実現できます。

ここからは、架空のタスク管理画面を題材に、このしくみだけで実際にどのようなインタラクションが実現できるのかを見ていきます。

アプリケーション全体をウォークスルーするアニメーション

表形式のタスク一覧をベースに、以下のようなインタラクションを組み込んでいます。これらがすべてページ遷移なしで動作しています。

  • インクリメンタル検索
  • セル単位のインライン編集
  • 確認ダイアログ付きの行削除
  • 詳細情報パネルの表示
  • 複数行の一括操作(Alpine.jsを併用)
  • タグ入力UI(Reactを併用)

便宜上、バックエンドのコードはFlask(Python製のシンプルなウェブフレームワーク)で示します。それでは、各インタラクションの実装を順に見ていきましょう。

インクリメンタル検索

検索ボックスに文字を入力すると、タスク一覧がリアルタイムに絞り込まれる機能です。

インクリメンタル検索のアニメーション。見出し横にある検索ボックスに「商品」と入力すると、それに応じてタスク一覧表が絞り込まれて表示される。検索ボックスを空に戻すと絞り込みが解除される。
<input
  type="search"
  name="q"
  placeholder="タスクを検索..."
  hx-get="/tasks/search"
  hx-trigger="input changed delay:300ms"
  hx-target="#task-table-body"
  hx-indicator="#search-indicator"
>
<span id="search-indicator" class="htmx-indicator">検索中...</span>

4つのhtmx属性だけで検索機能が実現されています。

hx-get="/tasks/search"
GETリクエストの送信先URL
hx-trigger="input changed delay:300ms"
入力が変更されてから300ms後にリクエストを発火(いわゆるデバウンス)
hx-target="#task-table-body"
レスポンスのHTMLを差し込む先の要素
hx-indicator="#search-indicator"
リクエスト中に表示するローディングインジケータ

サーバー側は、検索クエリに応じたタスク一覧のHTML断片を返すだけです。

@app.route("/tasks/search")
def search_tasks():
    query = request.args.get("q", "").strip()
    tasks = Task.search(query) if query else Task.all()
    return render_template("partials/task_table_body.html", tasks=tasks)

partials/task_table_body.htmlはテーブルの<tbody>の中身だけを含むテンプレートです。htmxがこのHTMLを#task-table-body要素と入れ替えることで、ページ全体をリロードせずにテーブルが更新されます。

セル単位のインライン編集

テーブルの各セル(タイトル、ステータス、担当者)を個別に編集できる機能です。セルにマウスを乗せると編集ボタンが表示され、クリックするとその場で編集フォームに切り替わります。

インライン編集のアニメーション。タスクのタイトル横にある鉛筆アイコンをクリックすると入力フィールドが表示され、書き換えてチェックアイコンのボタンをクリックすると変更が反映される。「対応中」ステータス横にある鉛筆アイコンをクリックするとセレクトボックスが表示され、変更してチェックアイコンのボタンをクリックすると変更が反映される。

通常表示のセル

<td id="cell-title-{{ task.id }}" class="group">
  <div class="flex items-center">
    <span>{{ task.emoji }}</span>
    <span class="font-medium">{{ task.title }}</span>
    <button
      class="opacity-0 focus:opacity-100 group-hover:opacity-100"
      hx-get="/tasks/{{ task.id }}/title/edit"
      hx-target="#cell-title-{{ task.id }}"
      hx-swap="outerHTML"
    >
      ✏️
    </button>
  </div>
</td>

編集ボタンはhx-getで編集フォームのHTMLを取得し、hx-swap="outerHTML"で現在のセル全体を置換します。

編集フォーム

<td id="cell-title-{{ task.id }}">
  <form
    hx-patch="/tasks/{{ task.id }}/title"
    hx-target="#cell-title-{{ task.id }}"
    hx-swap="outerHTML"
  >
    <input type="text" name="title" value="{{ task.title }}" autofocus>
    <button type="submit">保存</button>
    <button
      type="button"
      hx-get="/tasks/{{ task.id }}/title"
      hx-target="#cell-title-{{ task.id }}"
      hx-swap="outerHTML"
    >
      キャンセル
    </button>
  </form>
</td>

編集フォームは全体が<form>要素で囲まれています。

<form>要素にhx-patchを指定すると、フォーム内の各フィールドの値が通常のフォーム送信と同じように収集され、PATCHリクエストのボディとして送信されます。キャンセルボタンはhx-getで元の表示用セルを再取得して戻すしくみです。

サーバー側は、編集フォームの表示と更新処理をそれぞれ担当します。

# 編集フォームを返す
@app.route("/tasks/<int:task_id>/title/edit")
def edit_title_form(task_id):
    task = Task.find(task_id)
    return render_template("partials/edit_title.html", task=task)

# 表示用セルの取得 / タイトルの更新
@app.route("/tasks/<int:task_id>/title", methods=["GET", "PATCH"])
def title_resource(task_id):
    task = Task.find(task_id)
    if request.method == "PATCH":
        task.update(title=request.form.get("title", task.title))
    return render_template("partials/cell_title.html", task=task)

ステータスや担当者のセルも同じパターンで実装できます。セルごとに独立したHTMLテンプレートとエンドポイントを用意すればよいのです。

確認ダイアログ付きの行削除

確認ダイアログを表示しつつ、タスクを削除して、フェードアウトで行を消す機能です。

確認ダイアログ付きの行削除のアニメーション。行末にある「削除」ボタンをクリックするとブラウザの確認ダイアログが表示される。「OK」ボタンを押すと、当該行がフェードアウトした上で非表示になる。
<button
  hx-delete="/tasks/{{ task.id }}"
  hx-target="#task-row-{{ task.id }}"
  hx-swap="outerHTML swap:500ms"
  hx-confirm="「{{ task.title }}」を削除しますか?"
>
  削除
</button>

hx-confirmでブラウザ標準の確認ダイアログを表示し、ユーザーが承認した場合のみDELETEリクエストが送信されます。hx-swap="outerHTML swap:500ms"swap:500msは、レスポンスを受け取ってから実際にDOMを更新するまでの待ち時間です。htmxはこの待ち時間の開始時に対象要素へhtmx-swappingクラスを付与するため、CSSトランジションでフェードアウトを実現できます。

tr.htmx-swapping {
  opacity: 0;
  transition: opacity 500ms ease-out;
}

CSSのtransitionと組み合わせることで、JavaScriptを一切書かずにフェードアウトアニメーションが実現できます。

サーバー側は空のレスポンスを返すだけです。

@app.route("/tasks/<int:task_id>", methods=["DELETE"])
def delete_task(task_id):
    task = Task.find(task_id)
    if task:
        task.delete()
    return ""  # 空レスポンス → 行が消える

レスポンスの有無に関わらず要素を削除したい場合、hx-swap="delete swap:500ms"と書くことも可能です。

詳細情報パネル

行の「プレビュー」ボタンをクリックすると、右側のパネルにタスクの詳細情報を表示する機能です。

詳細情報パネルのアニメーション。各行に表示される「プレビュー」ボタンを次々にクリックすると、それに応じてテーブル右側のパネルの中身が当該タスクの詳細表示に切り替わる。

テーブル行にプレビューボタンを配置します。

<button
  hx-get="/tasks/{{ task.id }}/preview"
  hx-target="#detail-panel"
  hx-swap="innerHTML"
>
  プレビュー
</button>

ページ右側にはパネルのコンテナを用意しておきます。

<aside id="detail-panel">
  <p>タスクを選択してプレビュー</p>
</aside>

プレビューボタンをクリックすると、サーバーから詳細パネルのHTMLが返され、#detail-panelの中身が差し替わります。

詳細編集フォーム

詳細パネルには編集フォームも設けて、タスクの内容を編集・保存できるようにしています。

詳細編集フォームのアニメーション。詳細パネル下部の「詳細を編集」ボタンをクリックすると、詳細パネルが編集フォームに切り替わる。タイトル横の絵文字を絵文字ピッカーでハートの絵文字に変え、タイトルを一部修正し、「保存」する。詳細表示に戻り変更が反映される。

プレビューパネルの中に「詳細を編集」ボタンを配置します。

<button
  hx-get="/tasks/{{ task.id }}/edit/panel"
  hx-target="#detail-panel"
  hx-swap="innerHTML"
>
  詳細を編集
</button>

ボタンをクリックすると、サーバーから編集フォームのHTMLが返され、パネルの中身が編集フォームに差し替わります。レスポンスHTMLは次のようなイメージです。

<form
  hx-put="/tasks/{{ task.id }}/panel"
  hx-target="#detail-panel"
  hx-swap="innerHTML"
>
  <input type="text" name="title" value="{{ task.title }}">
  <select name="status">...</select>
  <select name="assignee">...</select>

  <button type="submit">保存</button>
  <button
    type="button"
    hx-get="/tasks/{{ task.id }}/preview"
    hx-target="#detail-panel"
    hx-swap="innerHTML"
  >
    キャンセル
  </button>
</form>

保存ボタンでhx-putリクエストが送信され、キャンセルボタンはhx-getでプレビュー表示に戻します。ここまでは、これまでのパターンと同じです。

ここで一つ考えなければならないことがあります。パネル内でタイトルやステータスを変更して保存すると、パネルの表示は更新されますが、左側のテーブル行には古い情報が残ったままになってしまいます

なぜでしょうか? フォームのPUTリクエストに対して返されるのは、パネルに表示する詳細情報のHTMLです。テーブル行はパネルとは別の場所にあり、このレスポンスには含まれません。つまり、パネルとテーブル行、ページ内の2ヵ所を1回のレスポンスで同時に更新する必要があるのです。

htmxはこうしたケースに対応するためにいくつかの手法を用意しています。そのうちのひとつが、OOB(Out-of-Band)Swapというしくみです。通常、htmxのレスポンスはhx-targetで指定した1ヵ所にだけ挿入されます。OOB Swapを使うと、レスポンスに含まれる特定の要素を、ターゲットとは別の場所にも同時に差し込むことができます。

サーバー側では、パネル用のHTMLに加えて、OOB用のテーブル行HTMLを連結して返します。

@app.route("/tasks/<int:task_id>/panel", methods=["PUT"])
def update_task_panel(task_id):
    task = Task.find(task_id)
    task.update(
        title=request.form.get("title"),
        description=request.form.get("description"),
        status=request.form.get("status"),
        assignee=request.form.get("assignee"),
    )
    # パネルのHTML + OOBでテーブル行も同時更新
    preview_html = render_template("partials/task_preview.html", task=task)
    row_html = render_template("partials/task_row_oob.html", task=task)
    return preview_html + row_html

OOB用のテーブル行テンプレートにはhx-swap-oob="true"属性を付与します。

<!-- <tr>要素は単独で存在できないため<template>タグで囲む必要あり -->
<template>
  <tr id="task-row-{{ task.id }}" hx-swap-oob="true">
    <!-- テーブル行の内容 -->
  </tr>
</template>

hx-swap-oob="true"を持つ要素は、通常のターゲット#detail-panelへの挿入とは別に、ページ内の同じidを持つ要素を自動的に探して置換します。これにより、パネルの更新とテーブル行の更新を1回のHTTPレスポンスで完結させられるのです。

複数行の一括操作(Alpine.jsを併用)

チェックボックスで複数の行を選択し、ステータスの一括変更や一括削除を行う機能です。

複数行の一括操作のアニメーション。IDが1、3、7の行にチェックをいれる。チェックをいれると画面下部に「3件選択中」と書かれたボックスが表示される。ボックス内にあるステータスのセレクトボックスから「完了」を選び「変更」ボタンをクリックすると、選択した行のステータスが「完了」に変化する。

この機能では「どの行が選択されているか」というクライアント側の状態管理が必要です。htmxはサーバーとの通信とDOM更新を得意としていますが、このような一時的なUI状態の管理には向いていません。こうした場面では、無理にhtmxだけで完結させようとせず、別のライブラリを活用するほうがよいでしょう。ここでは、HTML属性ベースでリアクティブな状態管理や表示制御を行える軽量なライブラリAlpine.jsを使った例を紹介します。

<div x-data="{ selectedIds: [] }">
  <!-- テーブル -->
  <table>
    <tbody id="task-table-body">
      {% for task in tasks %}
      <tr id="task-row-{{ task.id }}">
        <td>
          <input type="checkbox" name="task_ids" value="{{ task.id }}"
            x-model="selectedIds">
        </td>
        <!-- 他のセル -->
      </tr>
      {% endfor %}
    </tbody>
  </table>

  <!-- アクションバー(選択時のみ表示) -->
  <div x-show="selectedIds.length > 0" x-cloak x-transition>
    <span x-text="selectedIds.length + '件選択中'"></span>

    <form
      hx-post="/tasks/bulk-update"
      hx-target="#task-table-body"
      hx-include="[name='task_ids']"
      @htmx:after-request="selectedIds = []"
    >
      <label>
        ステータス
        <select name="status">
          <option value="">変更なし</option>
          <!-- 中略 -->
          <option value="完了">完了</option>
        </select>
      </label>

      <label>
        担当者
        <select name="assignee">
          <option value="">変更なし</option>
          <!-- 中略 -->
        </select>
      </label>

      <button type="submit" name="action" value="update">変更</button>
      <button type="submit" name="action" value="delete">一括削除</button>
    </form>

    <button @click="selectedIds = []">選択解除</button>
  </div>
</div>

Alpine.jsの実装詳細については本記事のスコープ外であるため省略します。ここではAlpine.jsとhtmxのそれぞれの役割を大まかに整理しておきましょう。

Alpine.jsが担当する部分

  • チェックボックスの選択状態の管理
  • 選択件数の表示とアクションバーの表示/非表示の切り替え
  • 選択解除ボタンの処理

htmxが担当する部分

  • サーバーへの一括更新リクエストの送信
  • 選択されたタスクIDの収集
  • テーブル本体の更新

サーバー側は、一括更新または一括削除を行い、更新後のテーブル本体を返します。

@app.route("/tasks/bulk-update", methods=["POST"])
def bulk_update_tasks():
    task_ids = [int(id) for id in request.form.getlist("task_ids")]
    action = request.form.get("action")

    if action == "update":
        status = request.form.get("status")
        assignee = request.form.get("assignee")
        update_kwargs = {}
        if status:
            update_kwargs["status"] = status
        if assignee:
            update_kwargs["assignee"] = assignee
        if update_kwargs:
            Task.bulk_update(task_ids, **update_kwargs)
    elif action == "delete":
        Task.bulk_delete(task_ids)

    tasks = Task.all()
    return render_template("partials/task_table_body.html", tasks=tasks)

タグ入力UI(Reactを併用)

ここまでの例を見てきたように、htmxを使えばJavaScriptをほとんど書かなくても、かなり多彩なインタラクションが実現できます。ただし、あらゆるUIがhtmxだけで事足りるわけではありません。たとえばタグ入力UIのような、キーボードイベントの細かい制御や重複チェック、フォーカス管理といった、クライアント側で複雑な状態を扱う必要があるコンポーネントは、htmxだけでは厳しいでしょう。

タグ入力UIのアニメーション。「フロントエンド」と「マーケティング」のタグ入力フィールド内に入力されている。「マーケティング」を横のバツアイコンをクリックして削除し、「新機能」タグを追加する。

こうした場面では、必要な箇所にだけ部分的にReactを導入してみましょう。

Reactコンポーネントの実装

タグ入力コンポーネントは、Enter/カンマでタグを追加、Backspaceで最後のタグを削除、×ボタンで任意のタグを削除といった操作を提供します。

function TagInputComponent({ initialTags = [], name }) {
  const [tags, setTags] = useState(initialTags)
  const [input, setInput] = useState('')
  const inputRef = useRef(null)

  const addTag = (tagText) => {
    const trimmed = tagText.trim()
    if (!trimmed || tags.includes(trimmed)) return false
    setTags([...tags, trimmed])
    return true
  }

  const removeTag = (index) => {
    setTags(tags.filter((_, i) => i !== index))
  }

  const handleKeyDown = (e) => {
    if (e.key === 'Enter' || e.key === ',') {
      e.preventDefault()
      if (addTag(input)) setInput('')
    } else if (e.key === 'Backspace' && input === '' && tags.length > 0) {
      removeTag(tags.length - 1)
    }
  }

  return (
    <>
      <input type="hidden" name={name} value={JSON.stringify(tags)} />
      <div onClick={() => inputRef.current?.focus()}>
        {tags.map((tag, index) => (
          <span key={index}>
            {tag}
            <button type="button" onClick={() => removeTag(index)}>×</button>
          </span>
        ))}
        <input
          ref={inputRef}
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder={tags.length === 0 ? 'タグを入力...' : ''}
        />
      </div>
    </>
  )
}

ポイントは<input type="hidden">です。入力されたタグの配列をJSON文字列としてhidden inputに格納しています。htmxがフォームを送信するとき、このhidden inputの値が通常のフォームデータとして一緒に送信されます。

Web ComponentでReactコンポーネントをラップする

作成したReactコンポーネントをカスタム要素として登録します。

class TagInputElement extends HTMLElement {
  connectedCallback() {
    const name = this.getAttribute('name') || 'tags'
    const initialTags = JSON.parse(this.getAttribute('value') || '[]')

    this.root = createRoot(this)
    this.root.render(
      <TagInputComponent initialTags={initialTags} name={name} />
    )
  }

  disconnectedCallback() {
    this.root?.unmount()
  }
}

customElements.define('tag-input', TagInputElement)

このカスタム要素は、Reactコンポーネントを呼び出すだけの役割です。このようにすることで、<tag-input>要素がDOMに追加されたとき、自動的にReactコンポーネントがマウントされます。逆にDOMから削除されたときは、Reactのクリーンアップが行われるようになります。

編集パネルでの活用

詳細パネルの編集フォームに、タグ入力UIを組み込みます。

<form
  hx-put="/tasks/{{ task.id }}/panel"
  hx-target="#detail-panel"
  hx-swap="innerHTML"
>
  <input type="text" name="title" value="{{ task.title }}">
  <select name="status">...</select>
  <select name="assignee">...</select>
  
  <!-- タグ入力UI -->
  <tag-input name="tags" value="{{ task.tags | tojson }}"></tag-input>

  <button type="submit">保存</button>
  <button
    type="button"
    hx-get="/tasks/{{ task.id }}/preview"
    hx-target="#detail-panel"
    hx-swap="innerHTML"
  >
    キャンセル
  </button>
</form>

まとめ

htmxは、HTMLの属性を使ってサーバーとの通信とDOM更新を宣言的に記述するライブラリです。本記事ではタスク管理画面を題材に、インクリメンタル検索、インライン編集、行削除、詳細パネルといったインタラクションを実装しました。いずれも、書いたのはHTML属性の数行だけで、JavaScriptは一行も書かずに実現できました。

サーバーがHTMLを返すという従来のウェブのしくみをそのまま活かすため、フロントエンド向けのJSON APIを別途用意する必要もありません。工数を節約しながら、ページ遷移のないインタラクティブなアプリケーションを実現できます。

さらに、クライアント側の状態管理が必要な場面では、Alpine.jsやReactを使ってインタラクションを強化できることも見てきました。htmxだけですべてをまかなう必要はなく、必要に応じて他のライブラリを段階的に導入できる柔軟さも、htmxの大きな強みだと言えるでしょう。

なお、サーバーがHTMLを返し、それをページの一部に差し込むというアプローチを採るライブラリはhtmxだけではありません。Ruby on Railsに標準搭載されているHotwire Turbo、プログレッシブエンハンスメントに力点を置いたUnpoly、Server-Sent Eventsを活用したDatastarなど、同じ思想のライブラリは他にもあります。このアプローチ自体に関心を持った方は、あわせて調べてみるとおもしろいでしょう。

htmxでできることをもっと見てみたい方は、公式サイトのExamplesページがお勧めです。さまざまなUIパターンのコード例が簡潔にまとまっています。htmxの背景にある思想をより深く理解したい方には、htmxの作者であるCarson Grossらが執筆した書籍ハイパーメディアシステム—⁠—htmxとRESTによるシンプルで軽やかなウェブ開発⁠技術評論社、2025年)をお勧めします。本記事では触れなかったREST本来の概念やハイパーメディアの歴史も丁寧に解説されています。翻訳は筆者が担当しました。

参考リンク

htmx関連
書籍
デモアプリ

おすすめ記事

記事・ニュース一覧