HTMX + Alpine.js: モダンなハイパーメディア駆動開発の実践ガイド


HTMXとAlpine.jsは、React/Vueなどの重量級フレームワークに頼らず、サーバーサイドレンダリングとインタラクティブUIを両立できる軽量な組み合わせです。

HTMXとAlpine.jsとは

HTMX

HTMLの属性だけで、AJAX、WebSocket、サーバー送信イベントを扱えるライブラリです。

特徴:

  • HTML中心: JavaScript不要でAJAXリクエスト
  • 軽量: 14KB(gzip圧縮後)
  • プログレッシブエンハンスメント: JavaScriptなしでも動作
  • サーバーサイドレンダリング: HTMLを返すだけ

Alpine.js

Vue.jsの軽量版のような、宣言的UIライブラリです。

特徴:

  • 軽量: 15KB(gzip圧縮後)
  • 宣言的構文: Vue.jsライクな記法
  • ビルドステップ不要: CDNから直接使える
  • リアクティビティ: データ変更で自動更新

基本セットアップ

CDN経由で導入

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>HTMX + Alpine.js</title>

  <!-- HTMX -->
  <script src="https://unpkg.com/htmx.org@1.9.10"></script>

  <!-- Alpine.js -->
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.5/dist/cdn.min.js"></script>
</head>
<body>
  <div id="app">
    <!-- ここにコンテンツ -->
  </div>
</body>
</html>

npm経由で導入

npm install htmx.org alpinejs
// main.js
import 'htmx.org'
import Alpine from 'alpinejs'

window.Alpine = Alpine
Alpine.start()

HTMX基本: AJAX without JavaScript

シンプルなGETリクエスト

<!-- クリックでコンテンツをロード -->
<button
  hx-get="/api/message"
  hx-target="#result">
  メッセージ取得
</button>

<div id="result">
  <!-- ここにレスポンスHTMLが挿入される -->
</div>

サーバー側(例: Express.js)

app.get('/api/message', (req, res) => {
  res.send('<p>サーバーからのメッセージ</p>')
})

POSTリクエスト

<form hx-post="/api/users" hx-target="#user-list">
  <input type="text" name="name" placeholder="名前">
  <input type="email" name="email" placeholder="メール">
  <button type="submit">追加</button>
</form>

<ul id="user-list">
  <!-- 新規ユーザーがここに追加される -->
</ul>

サーバー側

app.post('/api/users', (req, res) => {
  const { name, email } = req.body
  const html = `<li>${name} (${email})</li>`
  res.send(html)
})

DELETEリクエスト

<ul>
  <li>
    太郎
    <button
      hx-delete="/api/users/1"
      hx-target="closest li"
      hx-swap="outerHTML">
      削除
    </button>
  </li>
</ul>

サーバー側

app.delete('/api/users/:id', (req, res) => {
  // DBから削除
  res.send('') // 空のレスポンスで要素が削除される
})

Alpine.js基本: リアクティブUI

データバインディング

<div x-data="{ count: 0 }">
  <p>カウント: <span x-text="count"></span></p>
  <button @click="count++">増やす</button>
  <button @click="count--">減らす</button>
</div>

条件付きレンダリング

<div x-data="{ open: false }">
  <button @click="open = !open">トグル</button>

  <div x-show="open" x-transition>
    <p>表示/非表示が切り替わります</p>
  </div>
</div>

ループレンダリング

<div x-data="{
  items: ['リンゴ', 'バナナ', 'オレンジ']
}">
  <ul>
    <template x-for="item in items" :key="item">
      <li x-text="item"></li>
    </template>
  </ul>
</div>

HTMX + Alpine.js の組み合わせ

インタラクティブな検索UI

<div x-data="{ query: '', loading: false }">
  <input
    type="text"
    x-model="query"
    @input.debounce.500ms="
      loading = true;
      htmx.ajax('GET', '/api/search?q=' + query, '#results').then(() => {
        loading = false;
      })
    "
    placeholder="検索...">

  <div x-show="loading">検索中...</div>

  <div id="results">
    <!-- 検索結果がここに表示される -->
  </div>
</div>

サーバー側

app.get('/api/search', (req, res) => {
  const query = req.query.q
  const results = db.search(query) // 検索ロジック

  const html = results.map(r =>
    `<div class="result">${r.title}</div>`
  ).join('')

  res.send(html)
})

モーダルダイアログ

<div x-data="{ modalOpen: false }">
  <button @click="modalOpen = true">モーダルを開く</button>

  <div
    x-show="modalOpen"
    @click.away="modalOpen = false"
    x-transition
    class="modal">

    <div class="modal-content">
      <h2>ユーザー情報</h2>

      <div
        hx-get="/api/user/123"
        hx-trigger="load"
        hx-indicator="#spinner">
        <div id="spinner" class="htmx-indicator">読込中...</div>
        <!-- ユーザー情報がここに表示される -->
      </div>

      <button @click="modalOpen = false">閉じる</button>
    </div>
  </div>
</div>

実践例: ToDoアプリ

HTML

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>ToDoアプリ</title>
  <script src="https://unpkg.com/htmx.org@1.9.10"></script>
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.5/dist/cdn.min.js"></script>
  <style>
    .completed { text-decoration: line-through; opacity: 0.6; }
  </style>
</head>
<body>
  <div x-data="{ editMode: false, editText: '' }">
    <h1>ToDoリスト</h1>

    <!-- 新規追加フォーム -->
    <form hx-post="/api/todos" hx-target="#todo-list" hx-swap="beforeend">
      <input type="text" name="text" placeholder="新しいタスク" required>
      <button type="submit">追加</button>
    </form>

    <!-- ToDoリスト -->
    <ul id="todo-list" hx-get="/api/todos" hx-trigger="load">
      <!-- サーバーから初期データがロードされる -->
    </ul>
  </div>
</body>
</html>

サーバー側(Express.js)

const express = require('express')
const app = express()

app.use(express.urlencoded({ extended: true }))

let todos = [
  { id: 1, text: 'HTMXを学ぶ', completed: false },
  { id: 2, text: 'Alpine.jsを学ぶ', completed: false }
]
let nextId = 3

// 全ToDo取得
app.get('/api/todos', (req, res) => {
  const html = todos.map(renderTodo).join('')
  res.send(html)
})

// 新規ToDo作成
app.post('/api/todos', (req, res) => {
  const todo = {
    id: nextId++,
    text: req.body.text,
    completed: false
  }
  todos.push(todo)
  res.send(renderTodo(todo))
})

// ToDo完了トグル
app.put('/api/todos/:id/toggle', (req, res) => {
  const todo = todos.find(t => t.id === parseInt(req.params.id))
  if (todo) {
    todo.completed = !todo.completed
    res.send(renderTodo(todo))
  }
})

// ToDo削除
app.delete('/api/todos/:id', (req, res) => {
  todos = todos.filter(t => t.id !== parseInt(req.params.id))
  res.send('')
})

// ToDoをHTMLにレンダリング
function renderTodo(todo) {
  return `
    <li id="todo-${todo.id}" class="${todo.completed ? 'completed' : ''}">
      <span>${todo.text}</span>
      <button
        hx-put="/api/todos/${todo.id}/toggle"
        hx-target="#todo-${todo.id}"
        hx-swap="outerHTML">
        ${todo.completed ? '未完了に戻す' : '完了'}
      </button>
      <button
        hx-delete="/api/todos/${todo.id}"
        hx-target="#todo-${todo.id}"
        hx-swap="outerHTML">
        削除
      </button>
    </li>
  `
}

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000')
})

高度な機能

楽観的UI更新

<button
  hx-post="/api/like"
  hx-swap="outerHTML"
  hx-indicator="#spinner">
  <span class="htmx-request">いいね中...</span>
  <span class="htmx-added">いいね済み</span>
</button>

無限スクロール

<div id="content">
  <div hx-get="/api/posts?page=1" hx-trigger="load" hx-swap="outerHTML">
    <!-- 最初のページ -->
  </div>
</div>

<div
  hx-get="/api/posts?page=2"
  hx-trigger="revealed"
  hx-swap="afterend">
  <!-- スクロールで表示されたら次のページをロード -->
</div>

WebSocketでのリアルタイム更新

<div hx-ext="ws" ws-connect="/ws">
  <div id="messages" ws-send>
    <!-- WebSocketメッセージがここに追加される -->
  </div>

  <form ws-send>
    <input name="message" placeholder="メッセージ">
    <button type="submit">送信</button>
  </form>
</div>

パフォーマンス最適化

プリフェッチ

<a
  href="/page2"
  hx-get="/page2"
  hx-trigger="mouseenter"
  hx-swap="innerHTML"
  hx-target="#content">
  次のページ(ホバーでプリフェッチ)
</a>

デバウンス

<input
  type="text"
  hx-get="/api/search"
  hx-trigger="keyup changed delay:500ms"
  hx-target="#results">

まとめ

HTMX + Alpine.jsは、SPAの複雑さを避けつつ、モダンなUXを実現できます。

メリット:

  • 軽量(合計30KB以下)
  • ビルドステップ不要
  • サーバーサイドレンダリングと相性が良い
  • SEO対応が容易
  • プログレッシブエンハンスメント

適したプロジェクト:

  • コンテンツ重視のWebアプリ
  • 管理画面・ダッシュボード
  • サーバーサイドレンダリング前提のアプリ
  • プロトタイピング

向いていないケース:

  • 複雑な状態管理が必要
  • オフライン対応必須
  • ネイティブアプリ風のSPA

Rails、Django、Laravel、Express.jsなど、あらゆるバックエンドフレームワークと組み合わせて、シンプルで保守性の高いフルスタックアプリケーションを構築できます。