htmxは、HTMLの属性を追加するだけで、インクリメンタル検索やインライン編集をはじめとするさまざまな動的なUI
本記事では、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>
hx-getはクリック時にGETリクエストを送信し、hx-targetはレスポンスのHTMLを挿入する先を指定する属性です。サーバーからは以下のようなHTML断片が返されています。
<p>こんにちは! <code><b>/hello</b></code> です!</p>
このように、HTML属性を使用し、
従来のアプローチとの違い
ReactやVue.
htmxの最大の特徴は、サーバーがJSONではなくHTMLの断片を返すということです。サーバーがHTMLを返すという点は従来のウェブアプリケーションと共通しますが、htmxが要求するのはページ全体ではなく必要な部分のHTMLだけである点がポイントです。
このしくみにより、以下のようなメリットが得られます。
- 複雑なビルド環境が不要
<script>タグで読み込むだけで使い始められます。- JavaScriptをほとんど書かずに済む
- HTML属性で動的UIを宣言できます。
- 開発量を劇的に減らせる
- フロントエンドのためのJSON APIは不要です。
- サーバーサイドの技術を選ばない
- Rails、Laravel、Djangoなど、HTMLを返せるフレームワークならどれでも使えます。
インストール方法
htmxの導入は非常に手軽です。最も簡単な方法は、CDN
<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.をプロジェクトの静的ファイルディレクトリに配置し、<script>タグで読み込めばOKです。
htmxで実現するさまざまなインタラクション
「HTMLの属性を足すだけ」
ここからは、架空のタスク管理画面を題材に、このしくみだけで実際にどのようなインタラクションが実現できるのかを見ていきます。
表形式のタスク一覧をベースに、以下のようなインタラクションを組み込んでいます。これらがすべてページ遷移なしで動作しています。
- インクリメンタル検索
- セル単位のインライン編集
- 確認ダイアログ付きの行削除
- 詳細情報パネルの表示
- 複数行の一括操作
(Alpine. jsを併用) - タグ入力UI
(Reactを併用)
便宜上、バックエンドのコードはFlask
インクリメンタル検索
検索ボックスに文字を入力すると、タスク一覧がリアルタイムに絞り込まれる機能です。
<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/はテーブルの<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テンプレートとエンドポイントを用意すればよいのです。
確認ダイアログ付きの行削除
確認ダイアログを表示しつつ、タスクを削除して、フェードアウトで行を消す機能です。
<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はこうしたケースに対応するためにいくつかの手法を用意しています。そのうちのひとつが、OOBhx-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を併用)
チェックボックスで複数の行を選択し、ステータスの一括変更や一括削除を行う機能です。
この機能では
<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.
Alpine.
- チェックボックスの選択状態の管理
- 選択件数の表示とアクションバーの表示/
非表示の切り替え - 選択解除ボタンの処理
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だけでは厳しいでしょう。
こうした場面では、必要な箇所にだけ部分的に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.
なお、サーバーがHTMLを返し、それをページの一部に差し込むというアプローチを採るライブラリはhtmxだけではありません。Ruby on Railsに標準搭載されているHotwire Turbo、プログレッシブエンハンスメントに力点を置いたUnpoly、Server-Sent Eventsを活用したDatastarなど、同じ思想のライブラリは他にもあります。このアプローチ自体に関心を持った方は、あわせて調べてみるとおもしろいでしょう。
htmxでできることをもっと見てみたい方は、公式サイトのExamplesページがお勧めです。さまざまなUIパターンのコード例が簡潔にまとまっています。htmxの背景にある思想をより深く理解したい方には、htmxの作者であるCarson Grossらが執筆した書籍
参考リンク
- htmx関連
-
- htmx公式サイト
- htmx Examples — さまざまなUIパターンの実装例
- 書籍
-
『ハイパーメディアシステム——htmxとRESTによるシンプルで軽やかなウェブ開発』 (技術評論社、2025年)
- デモアプリ