htmx 2.0 - シンプルなインタラクティブWebの実現

2025.12.11

公式ドキュメント

この記事の要点

• htmxはHTML属性だけでAJAX・WebSocket・CSSトランジションを実現
• htmx 2.0でgzipサイズが14KB→10KBに削減
• JavaScriptを書かずにインタラクティブなWebアプリを構築可能
• サーバーサイド言語を問わず使えるシンプルなアプローチ

htmxとは

htmxは、HTMLの属性だけでAJAXリクエスト、CSSトランジション、WebSocketなどを実現するライブラリです。JavaScriptを書かずにインタラクティブなWebアプリケーションを構築できます。

htmx 2.0の新機能

サイズの削減

  • htmx 1.x: 14KB (gzip)
  • htmx 2.0: 10KB (gzip)

新しい属性

<!-- hx-disabled-elt: リクエスト中に要素を無効化 -->
<button hx-post="/api/submit"
        hx-disabled-elt="this">
  送信
</button>

<!-- hx-on: イベントハンドラをより簡潔に -->
<div hx-on:htmx:after-request="alert('完了!')">
  ...
</div>

<!-- hx-inherit: 継承の制御 -->
<div hx-boost="true">
  <a href="/page" hx-inherit="false">
    ブーストを無効化
  </a>
</div>

基本的な使い方

AJAXリクエスト

<!-- GETリクエスト -->
<button hx-get="/api/users" hx-target="#user-list">
  ユーザー一覧を取得
</button>
<div id="user-list"></div>

<!-- POSTリクエスト -->
<form hx-post="/api/users" hx-target="#result">
  <input name="name" placeholder="名前">
  <input name="email" placeholder="メール">
  <button type="submit">登録</button>
</form>
<div id="result"></div>

トリガーのカスタマイズ

<!-- 入力時に検索(デバウンス付き) -->
<input type="search"
       name="q"
       hx-get="/search"
       hx-trigger="input changed delay:300ms"
       hx-target="#search-results">

<!-- スクロールで追加読み込み -->
<div hx-get="/api/posts?page=2"
     hx-trigger="revealed"
     hx-swap="afterend">
  Loading...
</div>

スワップ戦略

<!-- 内部を置換(デフォルト) -->
<div hx-get="/content" hx-swap="innerHTML">

<!-- 要素全体を置換 -->
<div hx-get="/content" hx-swap="outerHTML">

<!-- 末尾に追加 -->
<div hx-get="/content" hx-swap="beforeend">

<!-- トランジション付き -->
<div hx-get="/content" hx-swap="innerHTML transition:true">

サーバーサイドとの連携

Expressの例

app.get('/api/users', (req, res) => {
  const users = getUsers();

  // HTMLを直接返す
  res.send(`
    <ul>
      ${users.map(u => `<li>${u.name}</li>`).join('')}
    </ul>
  `);
});

// HX-Trigger ヘッダーでイベントを発火
app.post('/api/users', (req, res) => {
  const user = createUser(req.body);
  res.setHeader('HX-Trigger', 'userCreated');
  res.send(`<p>ユーザー ${user.name} を作成しました</p>`);
});

Go (Echo) の例

e.POST("/api/todos", func(c echo.Context) error {
    todo := createTodo(c.FormValue("title"))

    c.Response().Header().Set("HX-Trigger", "todoAdded")
    return c.HTML(200, fmt.Sprintf(
        `<li>%s</li>`, todo.Title,
    ))
})

拡張機能

WebSocket

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

<div hx-ext="ws" ws-connect="/chat">
  <div id="messages"></div>
  <form ws-send>
    <input name="message">
    <button>送信</button>
  </form>
</div>

Server-Sent Events

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

<div hx-ext="sse" sse-connect="/events">
  <div sse-swap="message">
    イベントを待機中...
  </div>
</div>

View Transitions API対応

<meta name="htmx-config" content='{"globalViewTransitions": true}'>

<style>
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.3s;
  }
</style>

いつhtmxを使うべきか

向いているケース:

  • サーバーサイドレンダリング中心のアプリ
  • シンプルなインタラクション
  • プログレッシブエンハンスメント
  • JavaScriptを最小限にしたい

向かないケース:

  • 複雑なクライアント状態管理
  • オフライン対応が必要
  • リッチなUIコンポーネント

背景 - なぜhtmxが再評価されているのか

2010年代後半のWeb開発はReact、Vue、Angularといった大規模JavaScriptフレームワーク中心でした。しかしSPA (Single Page Application) の複雑性、バンドルサイズ肥大化、ハイドレーションコスト、SEO問題などが蓄積し、2023年頃から「HTML over the wire」と呼ばれる揺り戻しが起きています。

htmx、Hotwire (Turbo + Stimulus)、Livewire、Unpoly などがこの流れの代表で、サーバーサイドでHTMLを生成してブラウザが差分を適用する 方式です。htmx 2.0は2024年6月にリリースされ、IE11/古いブラウザサポートを削除、モダンブラウザに絞ることでコードを整理しました。

htmx 2.0の主要な変更点

  1. IE11 サポート廃止 - XHR、querySelectorAll、Promiseがネイティブで使える前提に。
  2. WebSocket / SSE を拡張機能化 - コアから外部拡張 (htmx-ext-ws, htmx-ext-sse) に移動。
  3. hx-on 属性の強化 - イベントごとに分けた hx-on:click, hx-on::after-swap などを公式採用。
  4. View Transitions API の公式統合 - transition:true でネイティブ遷移アニメーション。
  5. プラグインAPIの整理 - 拡張作者向けの hooks が整備された。

詳細な機能解説

hx-swap戦略とOOB (Out of Band)

サーバーから返されたHTMLを、メインターゲット以外の場所にも反映させたい場合 hx-swap-oob を使います。

<!-- メインの差し替え -->
<div id="cart-items">...</div>
<!-- サーバーからのレスポンスに含める -->
<span id="cart-count" hx-swap-oob="true">3</span>

サーバー側はカートに商品を追加したあと、カート一覧に加えて #cart-count も同じレスポンスで返せば、2箇所が同時に更新されます。

hx-boost でプログレッシブエンハンスメント

通常のリンクやフォームをhtmx経由に昇格し、ページ全体の再読込を防ぐ機能です。

<body hx-boost="true">
  <nav>
    <a href="/about">About</a>
    <a href="/contact">Contact</a>
  </nav>
</body>

JSが無効でも通常のリンクとして機能するため、アクセシビリティを保ったまま SPA 風の体験を実現できます。

リクエストライフサイクルイベント

htmxは豊富なイベントを発火します。これを使えば複雑なUIロジックも属性だけで書けます。

<button
  hx-post="/api/like"
  hx-on:htmx:before-request="this.disabled = true"
  hx-on:htmx:after-request="this.disabled = false"
  hx-on:htmx:response-error="showToast('エラーが発生しました')">
  いいね
</button>

Indicator (ローディング表示)

<style>
  .htmx-indicator { display: none; }
  .htmx-request .htmx-indicator { display: inline; }
</style>

<button hx-get="/slow" hx-indicator="#spinner">
  読み込み
  <img id="spinner" class="htmx-indicator" src="/spinner.svg" alt="">
</button>

hx-validate と HTML標準バリデーション

<form hx-post="/signup" hx-validate="true">
  <input type="email" name="email" required>
  <input type="password" name="password" minlength="8" required>
  <button>登録</button>
</form>

送信前にブラウザ標準バリデーションが走り、エラーがあればリクエストは発行されません。

実践サンプル - インクリメンタル検索とライブ結果

サーバー (Node.js + Express) 側:

import express from "express";
const app = express();
const items = ["apple", "banana", "cherry", "durian", "elderberry"];

app.get("/search", (req, res) => {
  const q = (req.query.q || "").toLowerCase();
  const results = items.filter((item) => item.includes(q));
  res.send(`
    <ul>
      ${results.map((r) => `<li>${r}</li>`).join("")}
    </ul>
  `);
});

app.listen(3000);

クライアント側:

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <script src="https://unpkg.com/htmx.org@2.0.0"></script>
  </head>
  <body>
    <input
      type="search"
      name="q"
      placeholder="検索..."
      hx-get="/search"
      hx-trigger="input changed delay:300ms, search"
      hx-target="#results"
      hx-indicator="#loading">
    <span id="loading" class="htmx-indicator">検索中...</span>
    <div id="results"></div>
  </body>
</html>

わずかこれだけで、デバウンス付きインクリメンタル検索が実装できます。

実践サンプル - 無限スクロール

<div id="posts">
  <!-- 初期投稿 -->
</div>

<div
  hx-get="/posts?page=2"
  hx-trigger="revealed"
  hx-swap="outerHTML">
  <span class="htmx-indicator">読み込み中...</span>
</div>

サーバーは次ページのHTMLと、そのさらに次のページを読み込むトリガーdivを返すだけ。

htmx 1.x vs 2.x 比較

項目htmx 1.xhtmx 2.x
ブラウザサポートIE11+Evergreen のみ
サイズ (gzip)約14KB約10KB
WebSocket/SSEコア内蔵拡張機能
hx-on 構文hx-on="..." 一括hx-on:event 分離
View Transitions実験的正式サポート
設定メタタグhtmx-config同じ (拡張)

他技術との比較

技術方式学習コスト適した用途
htmx 2.xHTML属性 + サーバーHTMLCRUD、管理画面、ブログ
Hotwire (Turbo)Rails中心、Turbo Frame/StreamRails アプリ
Alpine.js宣言的JSクライアント状態のみ
React / Next.jsSPA / RSC大規模SPA
Svelte / SvelteKitコンパイル型中〜大規模
Livewire (Laravel)PHP状態をサーバー保持Laravel アプリ

ベストプラクティス

  1. サーバーはHTMLの断片を返す: JSONを返してクライアントでテンプレート化しない。HTMLそのものが API。
  2. HX-Request ヘッダで出しわけ: htmxリクエスト時は断片、通常アクセス時はフルページを返すと初回表示にも対応できる。
  3. 状態はURLに: タブやフィルタの状態は hx-push-url="true" でURLに同期し、ブラウザバックに対応。
  4. CSRF対策: フォームや非GETリクエストに hx-headers='{"X-CSRF-Token":"..."}' を付与。
  5. コンポーネント化: サーバー側のテンプレートエンジン (Jinja, ERB, Blade, Pug) のパーシャルを活用して再利用。
  6. アクセシビリティ: aria-live 領域を利用して動的更新をスクリーンリーダーに伝える。
  7. CSPフレンドリーに: インラインイベントに近い hx-on を多用するとCSPが緩くなる。重要な箇所は data-* + カスタムスクリプトで。

注意点・落とし穴

  • XSS: サーバーが生成するHTMLに未エスケープのユーザー入力が含まれると即XSS。テンプレートエンジンの自動エスケープを有効に。
  • オフライン非対応: 全てサーバーに依存するので、オフラインファーストアプリには不向き。
  • リアクティブ状態: 入力値がリアルタイムで相互に影響するような複雑UIは Alpine.js と併用するか、別のツールを検討。
  • 大量のDOM更新: 1万行のテーブルを差し替えるといった処理は仮想DOMより重くなる場合がある。
  • hx-onの注意: 属性値にJSを書くため大きなロジックには向かない。関数呼び出しに留める。

導入手順

  1. 既存のサーバーサイドレンダリングアプリに <script src="https://unpkg.com/htmx.org@2.0.0"> を追加。
  2. リンクやフォームを少しずつ hx-get / hx-post に置き換える。
  3. サーバー側に if request.headers.get("HX-Request") 分岐を追加し、断片とフルページを出し分ける。
  4. エラー処理、ローディング表示、バリデーションの共通ヘルパーを整備。
  5. 必要に応じて拡張 (htmx-ext-ws, htmx-ext-response-targets, htmx-ext-morphdom-swap) を追加。
  6. E2Eテスト (Playwright など) でリグレッションを自動化。

パフォーマンスの目安

項目SPA (React)htmx 2.x
初回JS100〜500KB10KB
初回TTFBサーバー軽めサーバーが重め
初回FCPやや遅速い
画面遷移高速 (クライアント)高速 (断片更新)
サーバー負荷低 (API)中 (HTML生成)

実測ではサーバー側テンプレート生成+ネットワーク往復で100〜200ms以下に収まれば、ユーザー体験はSPAと遜色ありません。

FAQ

Q1. htmxはJavaScriptを書かなくていいの? A. 大部分は不要です。ただしClient側で完結する細かい状態管理 (モーダル開閉、トグル) には Alpine.js などを併用するのがおすすめです。

Q2. SEOは問題ない? A. サーバーが完全なHTMLを返すため、SEOはSPAより有利です。hx-boost + hx-push-url でURL構造も保たれます。

Q3. React / Vue から移行すべき? A. 全てを移行する必要はありません。管理画面、フォーム中心のサブアプリなど、向いている部分だけを移行すると効果的です。

Q4. TypeScript は使えますか? A. htmxはHTMLベースなのでTypeScript型は不要ですが、サーバー側 (Hono, Elysia, Express + TS) を型安全にすればAPI境界で型を効かせられます。

Q5. 大規模チームでも使える? A. 使えます。ただしサーバー側テンプレートの規約 (パーシャル命名、レスポンスヘッダ運用) をチームで統一することが重要です。

まとめ

htmx 2.0は、HTMLの拡張だけでインタラクティブなWebアプリケーションを構築できる軽量ライブラリです。サーバーサイドレンダリングとの相性が良く、シンプルなアプリケーションでは複雑なJavaScriptフレームワークの代替となります。

  • SPAより軽量でSEOに強い
  • サーバー中心の開発でフロントの複雑さを減らせる
  • プログレッシブエンハンスメントが自然にできる
  • ただしリッチクライアントには不向き

「HTMLを返せば動く」というシンプルさは、中小規模サービスや管理画面において圧倒的な生産性をもたらします。2026年のWeb開発では SPA か htmx か、という択一ではなく「適材適所で両方を使う」スタンスが主流になっていくでしょう。

補足: 実例 - TODOアプリ完成版

以下は htmx 2.x と Hono (Cloudflare Workers / Node) で作る TODO アプリの最小実装です。フォーム送信、楽観更新、削除、空状態メッセージまでを含みます。

// server.ts
import { Hono } from "hono";
import { html } from "hono/html";

type Todo = { id: number; title: string; done: boolean };
const todos: Todo[] = [];
let nextId = 1;

const app = new Hono();

const layout = (children: string) => html`
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>htmx TODO</title>
    <script src="https://unpkg.com/htmx.org@2.0.0"></script>
  </head>
  <body>
    <h1>TODO</h1>
    ${children}
  </body>
</html>
`;

const todoItem = (t: Todo) => `
  <li id="todo-${t.id}">
    <label>
      <input type="checkbox" ${t.done ? "checked" : ""}
        hx-post="/todos/${t.id}/toggle"
        hx-target="#todo-${t.id}"
        hx-swap="outerHTML">
      ${t.done ? `<s>${t.title}</s>` : t.title}
    </label>
    <button
      hx-delete="/todos/${t.id}"
      hx-target="#todo-${t.id}"
      hx-swap="outerHTML">削除</button>
  </li>
`;

const todoList = () => `
  <ul id="todo-list">
    ${todos.length === 0 ? "<li>まだTODOがありません</li>" : todos.map(todoItem).join("")}
  </ul>
`;

app.get("/", (c) =>
  c.html(layout(`
    <form hx-post="/todos" hx-target="#todo-list" hx-swap="outerHTML"
          hx-on::after-request="this.reset()">
      <input name="title" required placeholder="やること">
      <button>追加</button>
    </form>
    ${todoList()}
  `)),
);

app.post("/todos", async (c) => {
  const body = await c.req.parseBody();
  todos.push({ id: nextId++, title: String(body.title), done: false });
  return c.html(todoList());
});

app.post("/todos/:id/toggle", (c) => {
  const id = Number(c.req.param("id"));
  const t = todos.find((t) => t.id === id);
  if (!t) return c.notFound();
  t.done = !t.done;
  return c.html(todoItem(t));
});

app.delete("/todos/:id", (c) => {
  const id = Number(c.req.param("id"));
  const idx = todos.findIndex((t) => t.id === id);
  if (idx === -1) return c.notFound();
  todos.splice(idx, 1);
  return c.html(""); // empty replacement removes the <li>
});

export default app;

このサンプルから分かるように、「どのURLを叩き、何を返すか」という設計だけで動的UIが成立するのがhtmxの強みです。状態管理ライブラリもビルドステップも不要で、テストは HTTP のリクエスト/レスポンスとして書けます。

参考リソース

この技術を体系的に学びたいですか?

未来学では東証プライム上場企業のITエンジニアが24時間サポート。月額24,800円から、退会金0円のオンラインIT塾です。

メールで無料相談する
← 一覧に戻る