最終更新:

htmx + Alpine.js実践ガイド: JavaScriptフレームワークなしのモダンWeb開発


React、Vue、Angularといった大規模JavaScriptフレームワークが主流となっている現在ですが、htmxとAlpine.jsを組み合わせることで、はるかにシンプルかつ効率的にモダンなウェブアプリケーションを構築できます。本記事では、この強力な組み合わせを実践的に解説します。

htmx + Alpine.jsの哲学

なぜこの組み合わせなのか

htmxは、HTML属性を使ってAJAXリクエストや部分的なページ更新を実現します。サーバーからHTMLフラグメントを受け取り、DOMに直接挿入するシンプルなアプローチです。

Alpine.jsは、「HTMLの中のTailwind CSS」と呼ばれるほど軽量で、インラインで動的な振る舞いを追加できます。

この2つを組み合わせることで:

  • htmx: サーバーとの通信とページ遷移
  • Alpine.js: クライアント側の UI ステート管理とインタラクション

という役割分担が明確になり、両者の強みを最大限に活かせます。

基本セットアップ

インストール

CDN経由で簡単に始められます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>htmx + Alpine.js App</title>

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

  <!-- Alpine.js -->
  <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>

  <!-- Tailwind CSS (オプション) -->
  <script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
  <!-- アプリケーションコンテンツ -->
</body>
</html>

npmでの管理も可能です:

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

window.Alpine = Alpine;
Alpine.start();

実践例1: インタラクティブなTodoアプリ

クライアント側の状態管理 (Alpine.js)

<div x-data="{
  todos: [],
  newTodo: '',
  filter: 'all',

  get filteredTodos() {
    if (this.filter === 'active') {
      return this.todos.filter(t => !t.completed);
    }
    if (this.filter === 'completed') {
      return this.todos.filter(t => t.completed);
    }
    return this.todos;
  },

  get stats() {
    return {
      total: this.todos.length,
      active: this.todos.filter(t => !t.completed).length,
      completed: this.todos.filter(t => t.completed).length
    };
  }
}">
  <!-- 統計表示 -->
  <div class="flex gap-4 mb-4">
    <div class="badge">
      全体: <span x-text="stats.total"></span>
    </div>
    <div class="badge">
      未完了: <span x-text="stats.active"></span>
    </div>
    <div class="badge">
      完了: <span x-text="stats.completed"></span>
    </div>
  </div>

  <!-- フィルター -->
  <div class="flex gap-2 mb-4">
    <button @click="filter = 'all'"
            :class="filter === 'all' ? 'btn-active' : 'btn'">
      すべて
    </button>
    <button @click="filter = 'active'"
            :class="filter === 'active' ? 'btn-active' : 'btn'">
      未完了
    </button>
    <button @click="filter = 'completed'"
            :class="filter === 'completed' ? 'btn-active' : 'btn'">
      完了
    </button>
  </div>

  <!-- Todo追加フォーム (htmx) -->
  <form hx-post="/api/todos"
        hx-target="#todo-list"
        hx-swap="beforeend"
        @htmx:after-request="newTodo = ''"
        class="mb-4">
    <input type="text"
           name="title"
           x-model="newTodo"
           placeholder="新しいタスク"
           class="input">
    <button type="submit" class="btn">追加</button>
  </form>

  <!-- Todoリスト -->
  <div id="todo-list">
    <template x-for="todo in filteredTodos" :key="todo.id">
      <div class="todo-item"
           :class="{ 'completed': todo.completed }"
           x-data="{ editing: false, editText: todo.title }">

        <div x-show="!editing" class="flex items-center gap-2">
          <!-- チェックボックス (htmx) -->
          <input type="checkbox"
                 :checked="todo.completed"
                 hx-patch="`/api/todos/${todo.id}/toggle`"
                 hx-swap="none">

          <span x-text="todo.title"
                @dblclick="editing = true"
                class="flex-1"></span>

          <button @click="editing = true" class="btn-sm">編集</button>

          <!-- 削除ボタン (htmx) -->
          <button hx-delete="`/api/todos/${todo.id}`"
                  hx-target="closest .todo-item"
                  hx-swap="outerHTML swap:0.5s"
                  class="btn-sm btn-danger">
            削除
          </button>
        </div>

        <!-- 編集モード -->
        <div x-show="editing" class="flex gap-2">
          <input type="text"
                 x-model="editText"
                 @keyup.enter="editing = false"
                 @keyup.escape="editing = false; editText = todo.title"
                 class="input flex-1">
          <button @click="editing = false" class="btn-sm">保存</button>
        </div>
      </div>
    </template>
  </div>
</div>

サーバー側 (Node.js/Express)

import express from 'express';

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

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

// Todo追加
app.post('/api/todos', (req, res) => {
  const todo = {
    id: nextId++,
    title: req.body.title,
    completed: false,
  };
  todos.push(todo);

  // HTMLフラグメントを返す
  res.send(`
    <div class="todo-item" x-data="{ editing: false, editText: '${todo.title}' }">
      <!-- 上記と同じ構造 -->
    </div>
  `);
});

// Todo完了/未完了切り替え
app.patch('/api/todos/:id/toggle', (req, res) => {
  const todo = todos.find(t => t.id === parseInt(req.params.id));
  if (todo) {
    todo.completed = !todo.completed;
  }
  res.status(204).send();
});

// Todo削除
app.delete('/api/todos/:id', (req, res) => {
  todos = todos.filter(t => t.id !== parseInt(req.params.id));
  res.status(200).send(''); // 空のレスポンスで要素を削除
});

app.listen(3000);

実践例2: リアルタイム検索

クライアント側

<div x-data="{
  query: '',
  results: [],
  isSearching: false,
  selectedIndex: -1,

  // キーボードナビゲーション
  selectNext() {
    this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
  },
  selectPrev() {
    this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
  },
  selectCurrent() {
    if (this.selectedIndex >= 0) {
      window.location.href = this.results[this.selectedIndex].url;
    }
  }
}">
  <!-- 検索フォーム -->
  <div class="relative">
    <input type="text"
           x-model="query"
           @input.debounce.300ms="$refs.searchForm.dispatchEvent(new Event('submit'))"
           @keydown.down.prevent="selectNext()"
           @keydown.up.prevent="selectPrev()"
           @keydown.enter.prevent="selectCurrent()"
           placeholder="検索..."
           class="input w-full">

    <!-- ローディングインジケーター -->
    <div x-show="isSearching" class="absolute right-3 top-3">
      <svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">
        <!-- スピナーアイコン -->
      </svg>
    </div>
  </div>

  <!-- 検索結果 -->
  <form x-ref="searchForm"
        hx-get="/api/search"
        hx-trigger="submit"
        hx-include="[x-model='query']"
        hx-target="#search-results"
        @htmx:before-request="isSearching = true"
        @htmx:after-request="isSearching = false">

    <div id="search-results" class="mt-4">
      <template x-for="(result, index) in results" :key="result.id">
        <a :href="result.url"
           class="search-result"
           :class="{ 'selected': index === selectedIndex }"
           @mouseenter="selectedIndex = index">
          <div class="font-bold" x-text="result.title"></div>
          <div class="text-sm text-gray-600" x-text="result.description"></div>
        </a>
      </template>

      <div x-show="query && results.length === 0 && !isSearching">
        検索結果が見つかりません
      </div>
    </div>
  </form>
</div>

サーバー側

app.get('/api/search', async (req, res) => {
  const query = req.query.q || '';

  if (!query) {
    return res.send('');
  }

  // データベース検索(例)
  const results = await db.search(query);

  // Alpine.jsで使用できるようにデータを埋め込む
  const html = results.map(r => `
    <a href="${r.url}"
       class="search-result"
       x-data="{ result: ${JSON.stringify(r)} }">
      <div class="font-bold" x-text="result.title"></div>
      <div class="text-sm text-gray-600" x-text="result.description"></div>
    </a>
  `).join('');

  res.send(html);
});

実践例3: モーダルとトースト通知

再利用可能なAlpine.jsコンポーネント

<!-- Alpine.jsグローバルストア -->
<script>
  document.addEventListener('alpine:init', () => {
    // モーダル管理
    Alpine.store('modal', {
      isOpen: false,
      title: '',
      content: '',

      open(title, content) {
        this.title = title;
        this.content = content;
        this.isOpen = true;
      },

      close() {
        this.isOpen = false;
      }
    });

    // トースト通知
    Alpine.store('toast', {
      messages: [],

      show(message, type = 'info', duration = 3000) {
        const id = Date.now();
        this.messages.push({ id, message, type });

        setTimeout(() => {
          this.messages = this.messages.filter(m => m.id !== id);
        }, duration);
      },

      success(message) {
        this.show(message, 'success');
      },

      error(message) {
        this.show(message, 'error');
      }
    });
  });
</script>

<!-- モーダルコンポーネント -->
<div x-show="$store.modal.isOpen"
     x-cloak
     @keydown.escape.window="$store.modal.close()"
     class="modal-overlay">
  <div class="modal-content"
       @click.outside="$store.modal.close()">
    <div class="modal-header">
      <h2 x-text="$store.modal.title"></h2>
      <button @click="$store.modal.close()">×</button>
    </div>
    <div class="modal-body" x-html="$store.modal.content"></div>
  </div>
</div>

<!-- トースト通知コンポーネント -->
<div class="toast-container">
  <template x-for="toast in $store.toast.messages" :key="toast.id">
    <div class="toast"
         :class="`toast-${toast.type}`"
         x-transition:enter="transition ease-out duration-300"
         x-transition:leave="transition ease-in duration-200">
      <span x-text="toast.message"></span>
      <button @click="$store.toast.messages = $store.toast.messages.filter(m => m.id !== toast.id)">
        ×
      </button>
    </div>
  </template>
</div>

<!-- 使用例 -->
<button hx-get="/api/user/123"
        hx-target="#user-modal-content"
        @htmx:after-request="$store.modal.open('ユーザー詳細', $event.detail.xhr.response)">
  ユーザー情報を表示
</button>

<form hx-post="/api/save"
      @htmx:after-request="$store.toast.success('保存しました')">
  <!-- フォーム内容 -->
</form>

実践例4: 無限スクロール

<div x-data="{
  page: 1,
  hasMore: true,
  isLoading: false
}"
     @scroll.window="
       if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 100 && hasMore && !isLoading) {
         page++;
         $refs.loadMore.click();
       }
     ">

  <div id="items-container">
    <!-- 初期アイテム -->
  </div>

  <!-- 無限スクロール用の隠しボタン -->
  <button x-ref="loadMore"
          hx-get="/api/items"
          hx-target="#items-container"
          hx-swap="beforeend"
          hx-vals="js:{ page: Alpine.$data($el).page }"
          @htmx:before-request="isLoading = true"
          @htmx:after-request="isLoading = false; hasMore = $event.detail.xhr.response.length > 0"
          style="display: none;">
  </button>

  <!-- ローディング表示 -->
  <div x-show="isLoading" class="text-center py-4">
    読み込み中...
  </div>

  <div x-show="!hasMore" class="text-center py-4">
    すべて読み込みました
  </div>
</div>

サーバー側

app.get('/api/items', (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const perPage = 20;
  const items = fetchItems(page, perPage);

  const html = items.map(item => `
    <div class="item">
      <h3>${item.title}</h3>
      <p>${item.description}</p>
    </div>
  `).join('');

  res.send(html);
});

実践例5: フォームバリデーション

<form x-data="{
  formData: {
    email: '',
    password: '',
    confirmPassword: ''
  },
  errors: {},
  touched: {},

  validateEmail() {
    if (!this.formData.email) {
      this.errors.email = 'メールアドレスは必須です';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.formData.email)) {
      this.errors.email = '有効なメールアドレスを入力してください';
    } else {
      delete this.errors.email;
    }
  },

  validatePassword() {
    if (!this.formData.password) {
      this.errors.password = 'パスワードは必須です';
    } else if (this.formData.password.length < 8) {
      this.errors.password = 'パスワードは8文字以上必要です';
    } else {
      delete this.errors.password;
    }
  },

  validateConfirmPassword() {
    if (this.formData.password !== this.formData.confirmPassword) {
      this.errors.confirmPassword = 'パスワードが一致しません';
    } else {
      delete this.errors.confirmPassword;
    }
  },

  get isValid() {
    return Object.keys(this.errors).length === 0 &&
           this.formData.email &&
           this.formData.password &&
           this.formData.confirmPassword;
  }
}"
      hx-post="/api/register"
      hx-disabled-elt="button[type='submit']"
      @htmx:after-request="$store.toast.success('登録しました')">

  <!-- メールアドレス -->
  <div class="form-group">
    <label>メールアドレス</label>
    <input type="email"
           x-model="formData.email"
           @blur="touched.email = true; validateEmail()"
           @input="touched.email && validateEmail()"
           :class="{ 'error': touched.email && errors.email }"
           class="input">
    <span x-show="touched.email && errors.email"
          x-text="errors.email"
          class="error-message"></span>
  </div>

  <!-- パスワード -->
  <div class="form-group">
    <label>パスワード</label>
    <input type="password"
           x-model="formData.password"
           @blur="touched.password = true; validatePassword()"
           @input="touched.password && validatePassword(); validateConfirmPassword()"
           :class="{ 'error': touched.password && errors.password }"
           class="input">
    <span x-show="touched.password && errors.password"
          x-text="errors.password"
          class="error-message"></span>
  </div>

  <!-- パスワード確認 -->
  <div class="form-group">
    <label>パスワード確認</label>
    <input type="password"
           x-model="formData.confirmPassword"
           @blur="touched.confirmPassword = true; validateConfirmPassword()"
           @input="touched.confirmPassword && validateConfirmPassword()"
           :class="{ 'error': touched.confirmPassword && errors.confirmPassword }"
           class="input">
    <span x-show="touched.confirmPassword && errors.confirmPassword"
          x-text="errors.confirmPassword"
          class="error-message"></span>
  </div>

  <button type="submit"
          :disabled="!isValid"
          class="btn">
    登録
  </button>
</form>

パフォーマンス最適化

1. htmxのリクエストキャッシング

<!-- 結果をキャッシュ -->
<button hx-get="/api/data"
        hx-cache="true">
  データを取得
</button>

2. Alpine.jsの遅延初期化

<!-- ビューポートに入るまで初期化を遅延 -->
<div x-data="expensiveComponent()"
     x-intersect="$el._x_dataStack[0].init()">
  <!-- コンポーネント内容 -->
</div>

3. htmx拡張機能の活用

<!-- 楽観的UI更新 -->
<button hx-post="/api/like"
        hx-ext="optimistic">
  いいね
</button>

まとめ

htmxとAlpine.jsの組み合わせは、以下の利点があります:

  • 学習コストが低い: HTML中心のアプローチで直感的
  • バンドルサイズが小さい: 合計約30KB (gzip圧縮後)
  • サーバーサイドレンダリング: SEOに有利
  • 段階的な導入: 既存のアプリに少しずつ追加可能
  • 開発速度: ボイラープレートが少なく、素早く実装できる

React/Vueのような大規模SPAが必要ないプロジェクトでは、htmx + Alpine.jsの方がシンプルで保守しやすいコードベースを実現できます。ぜひ試してみてください。