📓 memotty

htmx v4キャッチアップチュートリアル

🎯 目的 #

2026-08-28 に htmx 4.0.0 がリリースされました。
内部実装を XMLHttpRequest から fetch() に全面書き換えした大型アップデートで、
公式エッセイのタイトルから “The fetch()ening” と呼ばれています。

このチュートリアルでは、htmx 2 までの知識を前提に、v4 の変更点を
Go template ベースの小さなアプリで 1 つずつ手を動かして確認します。

  • STEP 1: v2 の知識がそのまま使えることを確認(hx-get / hx-target / hx-swap
  • STEP 2: 最大の破壊的変更「明示的継承(:inherited)」
  • STEP 3: イベント名の htmx:phase:action 形式への統一
  • STEP 4: 本体内蔵になった morph スワップ(innerMorph
  • STEP 5: hx-swap-oob の後継 <hx-partial> タグ
  • STEP 6: hx-boost と履歴(戻る/進む)の挙動変更
  • STEP 7: Alpine.js 連携(hx-alpine-compat 拡張)
  • STEP 8: 既存プロジェクトの移行チェック

関連ノート: 202506202203 【htmx】さくっと使い方 / 202511261458 Go templateでhtmx + alpine.js


v2 → v4 変更点サマリ #

項目htmx 2htmx 4
内部実装XMLHttpRequestfetch()(ストリーミング対応)
属性の継承暗黙(親→子に自動継承):inherited で明示(デフォルト無効)
イベント名htmx:beforeRequest 等の camelCasehtmx:before:request 等の htmx:phase:action 形式
戻る/進むlocalStorage のスナップショット復元再フェッチ(hx-history-cache 拡張で従来動作)
複数箇所更新hx-swap-oob<hx-partial> タグ(hx-swap-oob も残存)
morphidiomorph 拡張が必要本体内蔵(innerMorph / outerMorph / outerSync
SSE / WebSocketsse / ws 拡張fetch ベースの hx-sse / hx-ws / hx-multipart 拡張

変わらないもの(v2 の知識がそのまま通用する部分):

  • hx-get / hx-post / hx-put / hx-patch / hx-delete
  • hx-target / hx-trigger / hx-swap の基本値(innerHTML / outerHTML / beforeend など)
  • hx-boost / hx-push-url / hx-vals / hx-include(※継承だけ注意)
  • .htmx-indicator / .htmx-request の CSS クラス
  • htmx.trigger() / htmx.ajax() などの JS API

バージョン指定の注意: npm の latest タグは誤アップグレード防止のため
2027 年まで 2.x のままです。v4 を使うには htmx.org@4.0.0 の明示指定が必須です。


📁 ディレクトリ構成 #

最終的に次の構成になります。

htmx4-tutorial/
├─ main.go
└─ templates/
   ├─ layout.html.tmpl
   ├─ index.html.tmpl
   ├─ about.html.tmpl        ← STEP 6
   └─ fragments/
      ├─ time.html.tmpl      ← STEP 1
      ├─ action.html.tmpl    ← STEP 2
      ├─ slow.html.tmpl      ← STEP 3
      ├─ profile.html.tmpl   ← STEP 4
      ├─ send.html.tmpl      ← STEP 5
      └─ counter.html.tmpl   ← STEP 7

前提 #

  • Go 1.22 以上(net/http のメソッド付きルーティングパターンを使うため)
  • すべてローカル環境で完結する想定
mkdir htmx4-tutorial
cd htmx4-tutorial
go mod init example.com/htmx4-tutorial
mkdir -p templates/fragments

STEP 0: Go サーバーと htmx 4 の読み込み #

まずは htmx 4 を読み込んだ最小構成を作ります。

0-1. レイアウトテンプレート #

templates/layout.html.tmpl:

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <title>{{block "title" .}}{{.Title}}{{end}}</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />

  <!-- htmx 4(@4.0.0 の明示指定が必須。latest はまだ 2.x を指す) -->
  <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js"></script>

  <style>
    body { max-width: 720px; margin: 24px auto; padding: 0 16px; font-family: sans-serif; }
    section { padding: 16px; border: 1px solid #eee; border-radius: 8px; margin-bottom: 16px; }
  </style>
</head>
<body>
  {{block "body" .}}{{end}}
</body>
</html>

0-2. トップページテンプレート #

templates/index.html.tmpl:

{{define "title"}}htmx v4 チュートリアル{{end}}

{{define "body"}}
<h1>htmx v4 キャッチアップ</h1>
{{end}}

0-3. main.go #

main.go:

package main

import (
	"html/template"
	"log"
	"net/http"
	"path/filepath"
)

var indexTmpl = mustPage("index.html.tmpl")

func main() {
	http.HandleFunc("GET /{$}", handleIndex)

	log.Println("Listening on http://localhost:8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
	renderPage(w, indexTmpl, map[string]any{"Title": "htmx v4 Tutorial"})
}

func renderPage(w http.ResponseWriter, t *template.Template, data any) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := t.ExecuteTemplate(w, "layout.html.tmpl", data); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

// mustPage は layout → ページの順でパースする。
// 後からパースしたページ側の define が layout の block を上書きする。
func mustPage(name string) *template.Template {
	return template.Must(template.ParseFiles(
		filepath.Join("templates", "layout.html.tmpl"),
		filepath.Join("templates", name),
	))
}

ポイント:

  • GET /{$} は Go 1.22 のルーティングパターンで、/完全一致 させる書き方です
    / だけだと全パスにマッチしてしまうため)
  • Go template の define後からパースしたものが同名テンプレートを上書き します。
    ParseGlob に任せるとファイル名のアルファベット順に依存して壊れるので、
    ページごとに ParseFiles(layout, page) の順で明示的にパースしています

0-4. 動作確認 #

go run .

http://localhost:8080 で見出しが表示されれば OK です。


STEP 1: v2 と同じ基本形が動くことを確認する #

まず安心材料から。hx-get / hx-target / hx-swap / hx-indicatorv2 と同じ書き方のまま 動きます。

1-1. フラグメントテンプレート #

templates/fragments/time.html.tmpl:

<p>サーバー時刻: {{.Now}}</p>

1-2. main.go にフラグメント描画を追加 #

var 宣言を var ブロックに変え、フラグメント用のテンプレートセットを追加します。

import (
	// 既存 + これ
	"time"
)

var (
	indexTmpl = mustPage("index.html.tmpl")
	fragTmpl  = template.Must(template.ParseGlob(filepath.Join("templates", "fragments", "*.html.tmpl")))
)

ハンドラとヘルパを追加します。

func handleTime(w http.ResponseWriter, r *http.Request) {
	time.Sleep(300 * time.Millisecond) // インジケーターを見せるためのウェイト
	renderFragment(w, "time.html.tmpl", map[string]any{"Now": now()})
}

func renderFragment(w http.ResponseWriter, name string, data any) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := fragTmpl.ExecuteTemplate(w, name, data); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func now() string {
	return time.Now().Format("15:04:05")
}

main() にルートを追加:

http.HandleFunc("GET /time", handleTime)

1-3. index にセクションを追加 #

templates/index.html.tmpl{{define "body"}} 内、<h1> の下に追記します。

<section>
  <h2>STEP 1: v2 と同じ基本形</h2>
  <button hx-get="/time" hx-target="#time-area" hx-swap="innerHTML"
          hx-indicator="#time-loading">現在時刻を取得</button>
  <span id="time-loading" class="htmx-indicator">読み込み中…</span>
  <div id="time-area"></div>
</section>

1-4. 動作確認 #

  • ボタンを押すと /time の結果が #time-area に差し込まれる
  • 通信中だけ「読み込み中…」が表示される

.htmx-indicator(普段 opacity: 0、リクエスト中だけ表示)の仕組みも v2 と同じで、
この範囲では v4 を意識することは何もありません


STEP 2: 明示的継承(:inherited)— 最大の破壊的変更 #

v2 では hx-targethx-confirm を親要素に書くと、子要素に暗黙的に継承 されました。

<!-- htmx 2: 親の hx-confirm が子のボタンに効く -->
<div hx-confirm="実行しますか?">
    <button hx-delete="/item/1">削除</button>
</div>

この「離れた場所の属性が挙動を変える」暗黙継承は追いづらいという理由で、
v4 では 継承がデフォルト無効 になりました。上のコードは v4 では confirm が出ません。

継承させたい属性には :inherited サフィックスを付けます。

2-1. index にセクションを追加 #

<section>
  <h2>STEP 2: 明示的継承(:inherited)</h2>
  <div hx-target:inherited="#step2-result" hx-confirm:inherited="実行しますか?">
    <button hx-post="/actions/a">アクションA</button>
    <button hx-post="/actions/b">アクションB</button>
  </div>
  <div id="step2-result"></div>
</section>

2-2. サーバー側を追加 #

templates/fragments/action.html.tmpl:

<p>アクション {{.Name}} を実行しました({{.Now}})</p>

main.go にハンドラを追加(r.PathValue は Go 1.22 から):

func handleAction(w http.ResponseWriter, r *http.Request) {
	renderFragment(w, "action.html.tmpl", map[string]any{
		"Name": r.PathValue("name"),
		"Now":  now(),
	})
}

main() にルートを追加:

http.HandleFunc("POST /actions/{name}", handleAction)

2-3. 動作確認 #

  • どちらのボタンを押しても confirm ダイアログが出て、結果が #step2-result に入る
  • 試しに :inherited を外すと、confirm もターゲット指定も 効かなくなる ことを確認

2-4. 補足: 移行用の逃げ道 #

  • hx-disinherit(v2 の継承打ち消し属性)は 廃止。明示継承になったので不要です
  • 既存コードが大量にある場合は、設定でv2 の暗黙継承に戻せます
<meta name="htmx-config" content='{"implicitInheritance":true}'>

ただし新規コードでは :inherited を書くのが v4 流です。


STEP 3: イベント名が htmx:phase:action 形式に #

v4 ではイベント名が camelCase から コロン区切りの htmx:フェーズ:アクション 形式 に統一されました。

htmx 2htmx 4
htmx:beforeRequesthtmx:before:request
htmx:afterRequesthtmx:after:request
htmx:beforeSwaphtmx:before:swap
htmx:afterSwaphtmx:after:swap
htmx:configRequesthtmx:config:request
htmx:xhr:*(progress など)廃止(XHR 自体が無いため)
htmx:validation:*廃止(ネイティブのフォームバリデーションへ)

202511261458 Go templateでhtmx + alpine.js で作ったグローバル進捗バーを v4 のイベント名で移植してみます。

3-1. layout に進捗バーを追加 #

templates/layout.html.tmpl<style> に追記:

/* STEP 3: グローバル進捗バー */
#global-bar {
  position: fixed; top: 0; left: 0; right: 0; height: 3px;
  background: #2563eb;
  transform: scaleX(0); transform-origin: left;
  transition: transform .2s ease;
  z-index: 9999;
}

<body> の先頭にバー要素、末尾({{block "body" .}}{{end}} の後)にスクリプトを追加:

<body>
  <div id="global-bar"></div>

  {{block "body" .}}{{end}}

  <script>
    // v4 のイベント名(htmx:phase:action 形式)で進捗バーを制御
    document.addEventListener('htmx:before:request', () => {
      document.getElementById('global-bar').style.transform = 'scaleX(1)';
    });
    document.addEventListener('htmx:after:swap', () => {
      setTimeout(() => {
        document.getElementById('global-bar').style.transform = 'scaleX(0)';
      }, 150);
    });
  </script>
</body>

3-2. 遅いエンドポイントで確認する #

templates/fragments/slow.html.tmpl:

<p>1秒かかる処理が完了しました({{.Now}})</p>

main.go:

func handleSlow(w http.ResponseWriter, r *http.Request) {
	time.Sleep(1 * time.Second)
	renderFragment(w, "slow.html.tmpl", map[string]any{"Now": now()})
}

main()http.HandleFunc("GET /slow", handleSlow) を追加。

index にセクションを追加:

<section>
  <h2>STEP 3: 新イベント名の確認(進捗バー)</h2>
  <button hx-get="/slow" hx-target="#slow-area"
          hx-on:htmx:after:request="console.log('リクエスト完了')">遅いリクエスト(1秒)</button>
  <div id="slow-area"></div>
</section>

3-3. 動作確認 #

  • ボタンを押すと画面上部に青いバーが伸び、レスポンス後に消える
  • DevTools のコンソールに「リクエスト完了」が出る

hx-onhx-on:<イベント名> の記法は v2 と同じですが、
中に書くイベント名が v4 形式 になる点だけ注意です(hx-on:htmx:after:request など)。


STEP 4: morph スワップが本体機能に #

v2 で入力状態を保ったまま DOM を更新するには idiomorph 拡張が必要でしたが、
v4 では改良版アルゴリズムが 本体に内蔵 されました。hx-swap に指定するだけです。

動作
innerMorph要素の中身を morph(状態・フォーカスを保持)
outerMorph要素ごと morph
outerSyncターゲットの属性を同期し、子は置換(hx-boost のデフォルト)

4-1. 2秒ごとに自動更新される領域を作る #

templates/fragments/profile.html.tmpl:

<p>最終更新: {{.Now}}</p>
<label>メモ: <input id="memo" name="memo" placeholder="入力中も消えないか試す" /></label>

main.go:

func handleProfile(w http.ResponseWriter, r *http.Request) {
	renderFragment(w, "profile.html.tmpl", map[string]any{"Now": now()})
}

main()http.HandleFunc("GET /profile", handleProfile) を追加。

index にセクションを追加。まずはあえて innerHTML で書きます。

<section>
  <h2>STEP 4: morph スワップ(innerMorph)</h2>
  <div id="profile" hx-get="/profile" hx-trigger="load, every 2s" hx-swap="innerHTML"></div>
</section>

4-2. 動作確認(innerHTML → innerMorph) #

  1. innerHTML のまま、input に文字を入力してみる
    → 2秒ごとの更新で 入力値もフォーカスも吹き飛ぶ
  2. hx-swap="innerMorph" に変更して再度試す
    → 時刻は更新されるのに、入力値とフォーカスは保持される

morph は要素の id を手がかりに同一要素を保とうとするので、
保持したい要素には id を付けておくのがポイントです。
特定要素を morph 対象から外す hx-morph-skip 属性もあります。


STEP 5: <hx-partial> で複数箇所を一括更新 #

v2 の hx-swap-oob(out of band swap)の後継として、
v4 では <hx-partial> タグ が導入されました。レスポンス側でターゲットとスワップ方法を宣言します。

  • hx-target には CSS セレクタや closest li などの拡張セレクタを指定可能
  • id="..."hx-target="#..." のショートハンド
  • hx-swap のデフォルトは innerHTML
  • レスポンスが <hx-partial> だけなら、メインターゲットには何もしない

5-1. チャット風 UI を作る #

index にセクションを追加:

<section>
  <h2>STEP 5: hx-partial で複数箇所更新</h2>
  <form hx-post="/send" hx-on:htmx:after:request="this.reset()">
    <input name="message" placeholder="メッセージ" />
    <button>送信</button>
  </form>
  <p>送信数: <span id="msg-count">0</span></p>
  <ul id="messages"></ul>
</section>

5-2. レスポンス側で2箇所を指定する #

templates/fragments/send.html.tmpl:

<hx-partial hx-target="#messages" hx-swap="beforeend">
  <li>{{.Message}}({{.Now}})</li>
</hx-partial>
<hx-partial id="msg-count">{{.Count}}</hx-partial>

1つ目はリストへの追記(beforeend)、2つ目は id ショートハンドで件数の書き換えです。

main.go の import に "sync/atomic" を追加し、var ブロックにカウンタを追加:

var (
	// 既存 + これ
	msgCount atomic.Int64
)

ハンドラを追加:

func handleSend(w http.ResponseWriter, r *http.Request) {
	msg := r.FormValue("message")
	if msg == "" {
		msg = "(空メッセージ)"
	}
	renderFragment(w, "send.html.tmpl", map[string]any{
		"Message": msg,
		"Count":   msgCount.Add(1),
		"Now":     now(),
	})
}

main()http.HandleFunc("POST /send", handleSend) を追加。

5-3. 動作確認 #

  • 送信すると 1レスポンスでリスト追記と件数更新が同時に 行われる
  • フォーム自体(メインターゲット)は書き換えられない
    (レスポンスに <hx-partial> しか無いため)

hx-swap-oob も残っていますが、新規コードではこちらが推奨です。


STEP 6: hx-boost と履歴の挙動変更 #

6-1. hx-boost にも :inherited が必要 #

v2 の定番だった <body hx-boost="true"> は、継承の明示化に伴い書き方が変わります。

<!-- htmx 2 -->
<body hx-boost="true">

<!-- htmx 4 -->
<body hx-boost:inherited="true">

templates/layout.html.tmpl<body> を書き換え、ナビゲーションを追加します。

<body hx-boost:inherited="true">
  <div id="global-bar"></div>

  <nav>
    <a href="/">Home</a>
    <a href="/about">About</a>
  </nav>

  {{block "body" .}}{{end}}

<style>nav a { margin-right: 12px; } も足しておきます。

6-2. About ページを追加 #

templates/about.html.tmpl:

{{define "title"}}About{{end}}

{{define "body"}}
<h1>About</h1>
<p>hx-boost によって、ページ全体のリロードなしでこのページに遷移しています。</p>
<p>ブラウザの「戻る」を押すと、v4 ではキャッシュ復元ではなく再フェッチで前のページを表示します。</p>
{{end}}

main.go の var ブロックに aboutTmpl = mustPage("about.html.tmpl") を追加し、ハンドラとルートも追加:

func handleAbout(w http.ResponseWriter, r *http.Request) {
	renderPage(w, aboutTmpl, map[string]any{"Title": "About"})
}
http.HandleFunc("GET /about", handleAbout)

6-3. 動作確認と履歴の変更点 #

  • Home / About を行き来すると、フルリロードなしで遷移し、進捗バーが動く
  • boost 時のスワップはデフォルトで outerSync
    <body> 要素は置換せず、属性を同期して子要素を差し替える)

そして履歴まわりが v4 の大きな変更点です。

  • v2: 戻る/進む時に localStorage のページスナップショットから復元
    (サードパーティスクリプトと干渉して壊れたり、機密 HTML が localStorage に残る問題があった)
  • v4: スナップショットを廃止し、毎回サーバーへ再フェッチ
    (v2 のキャッシュミス時と同じ挙動に一本化)

従来のキャッシュ復元が必要な場合だけ、hx-history-cache 拡張(sessionStorage ベース)を追加します。


STEP 7: Alpine.js 連携が公式拡張に #

202511261458 Go templateでhtmx + alpine.js では、htmx で差し込んだ DOM に Alpine を効かせるため
htmx:afterSwapAlpine.initTree を手動実行するワークアラウンドを使っていました。

v4 では公式の hx-alpine-compat 拡張 がこの問題を吸収してくれます。

  • スワップ前に新しいフラグメントへ Alpine を初期化する
  • Alpine の MutationObserver を settle 完了まで遅延させ、
    中間状態の DOM を見て初期化が不安定になる問題を防ぐ

7-1. layout に読み込みを追加 #

htmx → 拡張 → Alpine の順 で読み込みます(Alpine は拡張より後、が重要)。

  <!-- htmx 4(@4.0.0 の明示指定が必須。latest はまだ 2.x を指す) -->
  <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js"></script>
  <!-- STEP 7: Alpine 連携拡張(htmx → 拡張 → Alpine の順で読み込む) -->
  <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-alpine-compat.js"></script>
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>

7-2. Alpine コンポーネントを htmx で差し込む #

templates/fragments/counter.html.tmpl:

<div x-data="{ n: 0 }">
  <button type="button" @click="n++">+1</button>
  <span x-text="n"></span>
</div>

main.go:

func handleCounter(w http.ResponseWriter, r *http.Request) {
	renderFragment(w, "counter.html.tmpl", nil)
}

main()http.HandleFunc("GET /counter", handleCounter) を追加。

index にセクションを追加:

<section>
  <h2>STEP 7: Alpine.js 連携</h2>
  <button hx-get="/counter" hx-target="#counter-area">Alpine コンポーネントを読み込む</button>
  <div id="counter-area"></div>
</section>

7-3. 動作確認 #

  • ボタンで読み込んだカウンターの「+1」が 手動の initTree なしで 動く

v2 時代の htmx:afterSwap + Alpine.initTree 方式を移植する場合は、
イベント名が htmx:after:swap に変わっている点に注意してください(旧名では発火しません)。


STEP 8: 既存プロジェクトの移行チェック #

最後に、htmx 2 で書かれた既存プロジェクトを v4 に上げるときの流れです。

8-1. 公式の upgrade-check CLI #

テンプレートディレクトリを走査して、継承依存・旧イベント名・廃止属性などを検出してくれます。

npx htmx.org@4.0.0 upgrade-check -- ./templates

公式は LLM / コーディングエージェント向けの移行用 skill ファイルも配布しています。

8-2. 移行チェックリスト #

  1. バージョン指定: CDN / package.json を htmx.org@4.0.0 に明示(latest は 2.x)
  2. 継承: 親要素に書いた hx-target / hx-confirm / hx-boost 等に :inherited を付ける
    (暫定対応なら implicitInheritance: true
  3. hx-disinherit: 削除する(廃止)
  4. イベント名: htmx:beforeRequesthtmx:before:request 等にリネーム
    hx-on: 内のイベント名も対象)
  5. htmx:xhr:* / htmx:validation:*: 依存していれば代替実装を検討(廃止)
  6. 履歴: 戻る時のキャッシュ復元に依存していれば hx-history-cache 拡張を追加
  7. 拡張: idiomorph → 内蔵 morph、sse / ws → hx-sse / hx-ws に置き換え

なお dist/ext/ には htmx-2-compat.js という 2.x 互換レイヤー拡張も同梱されているので、
段階移行の足がかりにできます。


まとめ #

  • STEP 0〜1: コア属性(hx-get / hx-target / hx-swap / インジケーター)は v2 と同じ
  • STEP 2: 継承は :inherited で明示。hx-disinherit は廃止(最大の移行ポイント)
  • STEP 3: イベント名は htmx:phase:action 形式。hx-on: 内も要リネーム
  • STEP 4: morph が内蔵に(innerMorph / outerMorph / outerSync
  • STEP 5: 複数箇所更新は <hx-partial> タグで宣言的に
  • STEP 6: hx-boost:inherited="true"、戻る/進むは再フェッチ方式に
  • STEP 7: Alpine 連携は hx-alpine-compat 拡張で手動 initTree が不要に
  • STEP 8: npx htmx.org@4.0.0 upgrade-check で機械的にチェックできる

内部が fetch() になったことで、ストリーミング(hx-sse / hx-ws / hx-multipart)や
View Transitions 連携(hx-swap="... transition:true")といった発展ネタもあります。
まずは本番投入前に、このチュートリアルの範囲を手元のプロジェクトで素振りしておくのがおすすめです。

参考リンク #