T-CREATOR

htmx と Alpine.js でインタラクティブな UI を作る

htmx と Alpine.js でインタラクティブな UI を作る

現代の Web 開発において、複雑なフレームワークに頼らずとも、軽量で強力なインタラクティブな UI を作成できることをご存知でしょうか?htmx と Alpine.js という 2 つのライブラリを組み合わせることで、従来の重厚な SPA フレームワークに匹敵する機能を、はるかに少ないコードで実現できます。

この記事では、htmx と Alpine.js の基本概念から実践的なアプリケーション開発まで、段階的に学んでいきます。特に、実際の開発で遭遇するエラーとその解決策、そして効率的な開発手法について詳しく解説します。

htmx と Alpine.js の基礎知識

htmx とは

htmx は、HTML に直接属性を追加するだけで、AJAX、CSS Transitions、WebSockets、Server Sent Events などの機能を利用できるライブラリです。従来の JavaScript を書く必要がなく、宣言的なアプローチでインタラクティブな機能を実装できます。

htmx の最大の特徴は、サーバーから返される HTML を直接 DOM に挿入できることです。これにより、サーバーサイドのテンプレートエンジンを活用しながら、SPA のような滑らかな体験を提供できます。

Alpine.js とは

Alpine.js は、Vue.js や React のようなリアクティブな機能を、わずか 14KB のライブラリで提供する軽量なフレームワークです。HTML に直接ディレクティブを記述することで、状態管理、イベントハンドリング、条件分岐などを実装できます。

Alpine.js の魅力は、学習コストが低く、既存の HTML に段階的に導入できることです。複雑なビルドプロセスも不要で、CDN から直接読み込んで即座に使用できます。

組み合わせる理由とメリット

htmx と Alpine.js を組み合わせることで、それぞれの得意分野を活かした効率的な開発が可能になります。

htmx の役割:

  • サーバーとの通信
  • DOM 要素の動的更新
  • フォーム送信の自動化

Alpine.js の役割:

  • クライアントサイドの状態管理
  • UI の条件分岐とループ
  • イベントハンドリング

この組み合わせにより、サーバーサイドの堅牢性とクライアントサイドの柔軟性を両立できます。

開発環境のセットアップ

必要なファイルの準備

まず、プロジェクトの基本構造を作成しましょう。以下のようなディレクトリ構造を推奨します。

markdownproject/
├── index.html
├── css/
│   └── style.css
├── js/
│   └── app.js
└── server/
    └── server.js

基本的な HTML 構造

最小限の HTML テンプレートを作成します。この構造が、すべての機能の基盤となります。

html<!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 アプリ</title>

    <!-- htmx と Alpine.js の読み込み -->
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
    <script
      defer
      src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
    ></script>

    <link rel="stylesheet" href="css/style.css" />
  </head>
  <body>
    <div id="app">
      <!-- ここにコンテンツが入ります -->
    </div>

    <script src="js/app.js"></script>
  </body>
</html>

ライブラリの読み込み方法

ライブラリの読み込みには、CDN を使用する方法とローカルファイルを使用する方法があります。

CDN を使用する場合(推奨):

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

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

ローカルファイルを使用する場合:

bash# htmx のダウンロード
curl -o js/htmx.min.js https://unpkg.com/htmx.org@1.9.10/dist/htmx.min.js

# Alpine.js のダウンロード
curl -o js/alpine.min.js https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js

基本的なインタラクション実装

ボタンクリックでのコンテンツ更新

最も基本的な機能から始めましょう。ボタンをクリックすると、サーバーから新しいコンテンツを取得して表示する機能を実装します。

HTML 部分:

html<div x-data="{ count: 0 }">
  <h2>カウンター: <span x-text="count"></span></h2>

  <!-- htmx でサーバーにリクエストを送信 -->
  <button
    hx-post="/api/increment"
    hx-target="#counter-display"
    hx-swap="innerHTML"
  >
    カウントアップ
  </button>

  <!-- 結果を表示するエリア -->
  <div id="counter-display"></div>
</div>

サーバーサイド(Node.js/Express):

javascriptapp.post('/api/increment', (req, res) => {
  // 現在のカウントを取得(実際のアプリではデータベースから)
  const currentCount = parseInt(req.body.count) || 0;
  const newCount = currentCount + 1;

  // HTML フラグメントを返す
  res.send(`
        <h2>新しいカウント: ${newCount}</h2>
        <p>更新時刻: ${new Date().toLocaleString()}</p>
    `);
});

フォーム送信とレスポンス表示

フォームの送信を htmx で自動化し、Alpine.js でバリデーションを実装します。

HTML 部分:

html<form
  x-data="contactForm()"
  hx-post="/api/contact"
  hx-target="#form-result"
  hx-swap="innerHTML"
>
  <div>
    <label for="name">お名前:</label>
    <input
      type="text"
      id="name"
      name="name"
      x-model="form.name"
      :class="{ 'error': errors.name }"
      required
    />
    <span
      x-show="errors.name"
      x-text="errors.name"
      class="error-message"
    ></span>
  </div>

  <div>
    <label for="email">メールアドレス:</label>
    <input
      type="email"
      id="email"
      name="email"
      x-model="form.email"
      :class="{ 'error': errors.email }"
      required
    />
    <span
      x-show="errors.email"
      x-text="errors.email"
      class="error-message"
    ></span>
  </div>

  <button type="submit" :disabled="!isValid">送信</button>
</form>

<div id="form-result"></div>

JavaScript 部分:

javascriptfunction contactForm() {
  return {
    form: {
      name: '',
      email: '',
    },
    errors: {},

    get isValid() {
      return (
        this.form.name.length > 0 &&
        this.form.email.includes('@')
      );
    },

    validate() {
      this.errors = {};

      if (this.form.name.length < 2) {
        this.errors.name =
          '名前は2文字以上で入力してください';
      }

      if (!this.form.email.includes('@')) {
        this.errors.email =
          '有効なメールアドレスを入力してください';
      }

      return Object.keys(this.errors).length === 0;
    },
  };
}

条件分岐による表示制御

Alpine.js の条件分岐機能を使って、動的に UI を切り替えます。

html<div
  x-data="{ 
    isLoggedIn: false, 
    user: null,
    showLoginForm: false 
}"
>
  <!-- ログイン状態に応じた表示切り替え -->
  <div x-show="!isLoggedIn">
    <h3>ログインしてください</h3>
    <button
      @click="showLoginForm = true"
      x-show="!showLoginForm"
    >
      ログイン
    </button>
  </div>

  <!-- ログインフォーム -->
  <div
    x-show="showLoginForm"
    x-transition:enter="transition ease-out duration-300"
    x-transition:enter-start="opacity-0 transform scale-95"
    x-transition:enter-end="opacity-100 transform scale-100"
  >
    <form
      hx-post="/api/login"
      hx-target="#login-result"
      @submit.prevent="handleLogin"
    >
      <input
        type="email"
        name="email"
        placeholder="メールアドレス"
        required
      />

      <input
        type="password"
        name="password"
        placeholder="パスワード"
        required
      />

      <button type="submit">ログイン</button>
      <button type="button" @click="showLoginForm = false">
        キャンセル
      </button>
    </form>

    <div id="login-result"></div>
  </div>

  <!-- ログイン後の表示 -->
  <div x-show="isLoggedIn">
    <h3>ようこそ、<span x-text="user?.name"></span>さん</h3>
    <button @click="logout()">ログアウト</button>
  </div>
</div>

高度な UI パターン

モーダルダイアログの実装

モーダルダイアログを htmx と Alpine.js で実装します。サーバーからモーダルの内容を動的に取得し、スムーズなアニメーションを追加します。

HTML 部分:

html<div x-data="modalManager()">
  <!-- モーダルを開くボタン -->
  <button
    hx-get="/api/user-details/123"
    hx-target="#modal-content"
    @click="openModal()"
  >
    ユーザー詳細を表示
  </button>

  <!-- モーダルオーバーレイ -->
  <div
    x-show="isOpen"
    x-transition:enter="transition ease-out duration-300"
    x-transition:enter-start="opacity-0"
    x-transition:enter-end="opacity-100"
    x-transition:leave="transition ease-in duration-200"
    x-transition:leave-start="opacity-100"
    x-transition:leave-end="opacity-0"
    class="modal-overlay"
    @click="closeModal()"
  >
    <!-- モーダルコンテンツ -->
    <div
      x-show="isOpen"
      x-transition:enter="transition ease-out duration-300"
      x-transition:enter-start="opacity-0 transform scale-95"
      x-transition:enter-end="opacity-100 transform scale-100"
      x-transition:leave="transition ease-in duration-200"
      x-transition:leave-start="opacity-100 transform scale-100"
      x-transition:leave-end="opacity-0 transform scale-95"
      class="modal-content"
      @click.stop
    >
      <div id="modal-content">
        <!-- サーバーから取得したコンテンツがここに表示されます -->
      </div>

      <button @click="closeModal()" class="modal-close">
        ×
      </button>
    </div>
  </div>
</div>

JavaScript 部分:

javascriptfunction modalManager() {
  return {
    isOpen: false,

    openModal() {
      this.isOpen = true;
      document.body.style.overflow = 'hidden';
    },

    closeModal() {
      this.isOpen = false;
      document.body.style.overflow = 'auto';
    },
  };
}

CSS 部分:

css.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.modal-content {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  max-width: 500px;
  width: 90%;
  position: relative;
  max-height: 80vh;
  overflow-y: auto;
}

.modal-close {
  position: absolute;
  top: 10px;
  right: 15px;
  background: none;
  border: none;
  font-size: 24px;
  cursor: pointer;
  color: #666;
}

.modal-close:hover {
  color: #000;
}

タブ切り替え機能

タブ切り替え機能を実装し、各タブのコンテンツを htmx で動的に読み込みます。

html<div x-data="{ activeTab: 'tab1' }">
  <!-- タブナビゲーション -->
  <div class="tab-nav">
    <button
      @click="activeTab = 'tab1'"
      :class="{ 'active': activeTab === 'tab1' }"
      hx-get="/api/tab1-content"
      hx-target="#tab-content"
      hx-trigger="click"
    >
      タブ1
    </button>

    <button
      @click="activeTab = 'tab2'"
      :class="{ 'active': activeTab === 'tab2' }"
      hx-get="/api/tab2-content"
      hx-target="#tab-content"
      hx-trigger="click"
    >
      タブ2
    </button>

    <button
      @click="activeTab = 'tab3'"
      :class="{ 'active': activeTab === 'tab3' }"
      hx-get="/api/tab3-content"
      hx-target="#tab-content"
      hx-trigger="click"
    >
      タブ3
    </button>
  </div>

  <!-- タブコンテンツ -->
  <div
    id="tab-content"
    x-transition:enter="transition ease-out duration-300"
    x-transition:enter-start="opacity-0 transform translate-y-2"
    x-transition:enter-end="opacity-100 transform translate-y-0"
  >
    <!-- サーバーから取得したコンテンツがここに表示されます -->
  </div>
</div>

動的なリスト操作

リストの追加、削除、並び替えを htmx と Alpine.js で実装します。

html<div x-data="listManager()">
  <!-- 新しいアイテムの追加 -->
  <form @submit.prevent="addItem()" class="add-form">
    <input
      type="text"
      x-model="newItem"
      placeholder="新しいアイテムを入力"
      required
    />
    <button type="submit">追加</button>
  </form>

  <!-- アイテムリスト -->
  <ul id="item-list" class="item-list">
    <template x-for="(item, index) in items" :key="item.id">
      <li
        class="item"
        x-transition:enter="transition ease-out duration-300"
        x-transition:enter-start="opacity-0 transform scale-95"
        x-transition:enter-end="opacity-100 transform scale-100"
        x-transition:leave="transition ease-in duration-200"
        x-transition:leave-start="opacity-100 transform scale-100"
        x-transition:leave-end="opacity-0 transform scale-95"
      >
        <span x-text="item.text"></span>

        <div class="item-actions">
          <button
            @click="editItem(item.id)"
            class="edit-btn"
          >
            編集
          </button>

          <button
            @click="deleteItem(item.id)"
            class="delete-btn"
            hx-delete="/api/items/${item.id}"
            hx-target="#item-list"
            hx-swap="outerHTML"
          >
            削除
          </button>
        </div>
      </li>
    </template>
  </ul>

  <!-- 並び替えボタン -->
  <div class="sort-controls">
    <button @click="sortByName()">名前順</button>
    <button @click="sortByDate()">日付順</button>
  </div>
</div>

JavaScript 部分:

javascriptfunction listManager() {
  return {
    items: [],
    newItem: '',

    init() {
      this.loadItems();
    },

    async loadItems() {
      const response = await fetch('/api/items');
      this.items = await response.json();
    },

    async addItem() {
      if (!this.newItem.trim()) return;

      const response = await fetch('/api/items', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ text: this.newItem }),
      });

      if (response.ok) {
        this.items.push(await response.json());
        this.newItem = '';
      }
    },

    async deleteItem(id) {
      this.items = this.items.filter(
        (item) => item.id !== id
      );
    },

    sortByName() {
      this.items.sort((a, b) =>
        a.text.localeCompare(b.text)
      );
    },

    sortByDate() {
      this.items.sort(
        (a, b) =>
          new Date(b.createdAt) - new Date(a.createdAt)
      );
    },
  };
}

実践的なサンプルアプリケーション

タスク管理アプリの作成

完全なタスク管理アプリケーションを作成します。これにより、htmx と Alpine.js の実践的な使用方法を学べます。

メイン HTML:

html<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>タスク管理アプリ</title>

    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
    <script
      defer
      src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
    ></script>

    <link rel="stylesheet" href="css/task-app.css" />
  </head>
  <body>
    <div id="app" x-data="taskApp()">
      <header class="app-header">
        <h1>タスク管理アプリ</h1>
        <div class="stats">
          <span
            >完了: <span x-text="completedCount"></span
          ></span>
          <span
            >未完了: <span x-text="pendingCount"></span
          ></span>
        </div>
      </header>

      <main class="app-main">
        <!-- 新しいタスクの追加 -->
        <div class="add-task-section">
          <form
            @submit.prevent="addTask()"
            hx-post="/api/tasks"
            hx-target="#task-list"
            hx-swap="beforeend"
          >
            <input
              type="text"
              x-model="newTask.title"
              placeholder="タスクのタイトル"
              required
            />

            <textarea
              x-model="newTask.description"
              placeholder="タスクの詳細(任意)"
            ></textarea>

            <input type="date" x-model="newTask.dueDate" />

            <select x-model="newTask.priority">
              <option value="low"></option>
              <option value="medium"></option>
              <option value="high"></option>
            </select>

            <button type="submit">タスクを追加</button>
          </form>
        </div>

        <!-- フィルターとソート -->
        <div class="controls">
          <select x-model="filter" @change="filterTasks()">
            <option value="all">すべて</option>
            <option value="pending">未完了</option>
            <option value="completed">完了</option>
          </select>

          <select x-model="sortBy" @change="sortTasks()">
            <option value="created">作成日</option>
            <option value="due">期限</option>
            <option value="priority">優先度</option>
          </select>
        </div>

        <!-- タスクリスト -->
        <div id="task-list" class="task-list">
          <!-- サーバーから取得したタスクがここに表示されます -->
        </div>
      </main>
    </div>

    <script src="js/task-app.js"></script>
  </body>
</html>

JavaScript 部分:

javascriptfunction taskApp() {
  return {
    tasks: [],
    newTask: {
      title: '',
      description: '',
      dueDate: '',
      priority: 'medium',
    },
    filter: 'all',
    sortBy: 'created',

    get completedCount() {
      return this.tasks.filter((task) => task.completed)
        .length;
    },

    get pendingCount() {
      return this.tasks.filter((task) => !task.completed)
        .length;
    },

    init() {
      this.loadTasks();
    },

    async loadTasks() {
      try {
        const response = await fetch('/api/tasks');
        if (response.ok) {
          this.tasks = await response.json();
        }
      } catch (error) {
        console.error(
          'タスクの読み込みに失敗しました:',
          error
        );
        this.showError('タスクの読み込みに失敗しました');
      }
    },

    async addTask() {
      if (!this.newTask.title.trim()) return;

      try {
        const response = await fetch('/api/tasks', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(this.newTask),
        });

        if (response.ok) {
          const newTask = await response.json();
          this.tasks.push(newTask);

          // フォームをリセット
          this.newTask = {
            title: '',
            description: '',
            dueDate: '',
            priority: 'medium',
          };

          this.showSuccess('タスクが追加されました');
        }
      } catch (error) {
        console.error('タスクの追加に失敗しました:', error);
        this.showError('タスクの追加に失敗しました');
      }
    },

    async toggleTask(id) {
      try {
        const response = await fetch(
          `/api/tasks/${id}/toggle`,
          {
            method: 'PATCH',
          }
        );

        if (response.ok) {
          const updatedTask = await response.json();
          const index = this.tasks.findIndex(
            (task) => task.id === id
          );
          if (index !== -1) {
            this.tasks[index] = updatedTask;
          }
        }
      } catch (error) {
        console.error('タスクの更新に失敗しました:', error);
        this.showError('タスクの更新に失敗しました');
      }
    },

    filterTasks() {
      // htmx でフィルターされたタスクを取得
      htmx.ajax('GET', `/api/tasks?filter=${this.filter}`, {
        target: '#task-list',
        swap: 'innerHTML',
      });
    },

    sortTasks() {
      // htmx でソートされたタスクを取得
      htmx.ajax('GET', `/api/tasks?sort=${this.sortBy}`, {
        target: '#task-list',
        swap: 'innerHTML',
      });
    },

    showSuccess(message) {
      // 成功メッセージの表示
      this.showNotification(message, 'success');
    },

    showError(message) {
      // エラーメッセージの表示
      this.showNotification(message, 'error');
    },

    showNotification(message, type) {
      const notification = document.createElement('div');
      notification.className = `notification ${type}`;
      notification.textContent = message;

      document.body.appendChild(notification);

      setTimeout(() => {
        notification.remove();
      }, 3000);
    },
  };
}

リアルタイム検索機能

ユーザーが入力するとリアルタイムで検索結果が更新される機能を実装します。

html<div x-data="searchManager()" class="search-container">
  <!-- 検索入力フィールド -->
  <div class="search-input">
    <input
      type="text"
      x-model="searchQuery"
      @input.debounce.300ms="performSearch()"
      placeholder="検索キーワードを入力..."
      class="search-field"
    />

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

  <!-- 検索結果 -->
  <div id="search-results" class="search-results">
    <div
      x-show="searchQuery.length > 0 && results.length === 0 && !isLoading"
      class="no-results"
    >
      検索結果が見つかりませんでした
    </div>

    <div
      x-show="searchQuery.length === 0"
      class="search-hint"
    >
      キーワードを入力して検索を開始してください
    </div>
  </div>

  <!-- 検索履歴 -->
  <div
    x-show="searchHistory.length > 0"
    class="search-history"
  >
    <h4>最近の検索</h4>
    <ul>
      <template
        x-for="query in searchHistory.slice(0, 5)"
        :key="query"
      >
        <li>
          <button
            @click="searchQuery = query; performSearch()"
            x-text="query"
            class="history-item"
          ></button>
        </li>
      </template>
    </ul>
  </div>
</div>

JavaScript 部分:

javascriptfunction searchManager() {
  return {
    searchQuery: '',
    results: [],
    isLoading: false,
    searchHistory: [],

    init() {
      // ローカルストレージから検索履歴を読み込み
      this.searchHistory = JSON.parse(
        localStorage.getItem('searchHistory') || '[]'
      );
    },

    async performSearch() {
      if (!this.searchQuery.trim()) {
        this.results = [];
        return;
      }

      this.isLoading = true;

      try {
        const response = await fetch(
          `/api/search?q=${encodeURIComponent(
            this.searchQuery
          )}`
        );

        if (response.ok) {
          this.results = await response.json();

          // 検索履歴に追加
          this.addToHistory(this.searchQuery);
        }
      } catch (error) {
        console.error('検索に失敗しました:', error);
        this.results = [];
      } finally {
        this.isLoading = false;
      }
    },

    addToHistory(query) {
      // 重複を削除
      this.searchHistory = this.searchHistory.filter(
        (q) => q !== query
      );

      // 先頭に追加
      this.searchHistory.unshift(query);

      // 最大10件まで保持
      this.searchHistory = this.searchHistory.slice(0, 10);

      // ローカルストレージに保存
      localStorage.setItem(
        'searchHistory',
        JSON.stringify(this.searchHistory)
      );
    },
  };
}

ページネーション機能

大量のデータを効率的に表示するためのページネーション機能を実装します。

html<div
  x-data="paginationManager()"
  class="pagination-container"
>
  <!-- データテーブル -->
  <table class="data-table">
    <thead>
      <tr>
        <th>ID</th>
        <th>名前</th>
        <th>メール</th>
        <th>作成日</th>
        <th>操作</th>
      </tr>
    </thead>
    <tbody id="table-body">
      <!-- サーバーから取得したデータがここに表示されます -->
    </tbody>
  </table>

  <!-- ページネーションコントロール -->
  <div class="pagination-controls">
    <div class="pagination-info">
      <span><span x-text="totalItems"></span> 件中</span>
      <span
        ><span x-text="startItem"></span> -
        <span x-text="endItem"></span> 件を表示</span
      >
    </div>

    <div class="pagination-buttons">
      <!-- 最初のページ -->
      <button
        @click="goToPage(1)"
        :disabled="currentPage === 1"
        class="page-btn"
      >
        最初
      </button>

      <!-- 前のページ -->
      <button
        @click="goToPage(currentPage - 1)"
        :disabled="currentPage === 1"
        class="page-btn"
      >
        前へ
      </button>

      <!-- ページ番号 -->
      <template x-for="page in visiblePages" :key="page">
        <button
          @click="goToPage(page)"
          :class="{ 'active': page === currentPage }"
          class="page-btn"
          x-text="page"
        ></button>
      </template>

      <!-- 次のページ -->
      <button
        @click="goToPage(currentPage + 1)"
        :disabled="currentPage === totalPages"
        class="page-btn"
      >
        次へ
      </button>

      <!-- 最後のページ -->
      <button
        @click="goToPage(totalPages)"
        :disabled="currentPage === totalPages"
        class="page-btn"
      >
        最後
      </button>
    </div>

    <!-- ページサイズ選択 -->
    <div class="page-size-selector">
      <label>表示件数:</label>
      <select x-model="pageSize" @change="changePageSize()">
        <option value="10">10件</option>
        <option value="25">25件</option>
        <option value="50">50件</option>
        <option value="100">100件</option>
      </select>
    </div>
  </div>
</div>

JavaScript 部分:

javascriptfunction paginationManager() {
  return {
    currentPage: 1,
    pageSize: 25,
    totalItems: 0,
    totalPages: 0,

    get startItem() {
      return (this.currentPage - 1) * this.pageSize + 1;
    },

    get endItem() {
      return Math.min(
        this.currentPage * this.pageSize,
        this.totalItems
      );
    },

    get visiblePages() {
      const pages = [];
      const maxVisible = 5;

      let start = Math.max(
        1,
        this.currentPage - Math.floor(maxVisible / 2)
      );
      let end = Math.min(
        this.totalPages,
        start + maxVisible - 1
      );

      if (end - start + 1 < maxVisible) {
        start = Math.max(1, end - maxVisible + 1);
      }

      for (let i = start; i <= end; i++) {
        pages.push(i);
      }

      return pages;
    },

    init() {
      this.loadData();
    },

    async loadData() {
      try {
        const response = await fetch(
          `/api/data?page=${this.currentPage}&size=${this.pageSize}`
        );

        if (response.ok) {
          const data = await response.json();

          // テーブルボディを更新
          htmx.ajax(
            'GET',
            `/api/data/table?page=${this.currentPage}&size=${this.pageSize}`,
            {
              target: '#table-body',
              swap: 'innerHTML',
            }
          );

          this.totalItems = data.total;
          this.totalPages = Math.ceil(
            this.totalItems / this.pageSize
          );
        }
      } catch (error) {
        console.error(
          'データの読み込みに失敗しました:',
          error
        );
      }
    },

    goToPage(page) {
      if (page >= 1 && page <= this.totalPages) {
        this.currentPage = page;
        this.loadData();

        // URLを更新(ブラウザの戻る/進むボタン対応)
        const url = new URL(window.location);
        url.searchParams.set('page', page);
        window.history.pushState({}, '', url);
      }
    },

    changePageSize() {
      this.currentPage = 1; // ページサイズ変更時は最初のページに戻る
      this.loadData();
    },
  };
}

パフォーマンスとベストプラクティス

効率的な DOM 操作

htmx と Alpine.js を効率的に使用するためのベストプラクティスを紹介します。

1. 適切な htmx 属性の使用:

html<!-- 良い例:必要な部分のみ更新 -->
<div
  hx-get="/api/user-info"
  hx-target="#user-details"
  hx-swap="innerHTML"
>
  <div id="user-details">
    <!-- 更新される部分 -->
  </div>
</div>

<!-- 悪い例:全体を更新 -->
<div
  hx-get="/api/user-info"
  hx-target="this"
  hx-swap="outerHTML"
>
  <!-- 全体が更新される -->
</div>

2. 条件付きリクエスト:

html<!-- 条件付きでリクエストを送信 -->
<button
  hx-get="/api/data"
  hx-target="#results"
  hx-trigger="click[!event.target.disabled]"
  :disabled="isLoading"
>
  データを取得
</button>

3. デバウンスとスロットリング:

html<!-- 入力時のデバウンス -->
<input
  type="text"
  hx-get="/api/search"
  hx-target="#search-results"
  hx-trigger="keyup changed delay:500ms"
  placeholder="検索..."
/>

<!-- スクロール時のスロットリング -->
<div
  hx-get="/api/load-more"
  hx-target="this"
  hx-swap="beforeend"
  hx-trigger="intersect once"
>
  <!-- 無限スクロール -->
</div>

エラーハンドリング

実際の開発で遭遇するエラーとその解決策を紹介します。

1. ネットワークエラーの処理:

html<div x-data="errorHandler()">
  <!-- エラー表示エリア -->
  <div
    x-show="hasError"
    x-transition:enter="transition ease-out duration-300"
    x-transition:enter-start="opacity-0 transform scale-95"
    x-transition:enter-end="opacity-100 transform scale-100"
    class="error-message"
    x-text="errorMessage"
  ></div>

  <!-- フォーム送信 -->
  <form
    hx-post="/api/submit"
    hx-target="#result"
    hx-swap="innerHTML"
    @htmx:responseError="handleError($event)"
  >
    <!-- フォーム内容 -->
  </form>
</div>

JavaScript 部分:

javascriptfunction errorHandler() {
  return {
    hasError: false,
    errorMessage: '',

    handleError(event) {
      this.hasError = true;

      if (event.detail.xhr.status === 0) {
        this.errorMessage =
          'ネットワークエラーが発生しました。インターネット接続を確認してください。';
      } else if (event.detail.xhr.status === 404) {
        this.errorMessage =
          'リクエストされたリソースが見つかりません。';
      } else if (event.detail.xhr.status === 500) {
        this.errorMessage =
          'サーバーエラーが発生しました。しばらく時間をおいてから再試行してください。';
      } else {
        this.errorMessage =
          '予期しないエラーが発生しました。';
      }

      // 3秒後にエラーメッセージを消す
      setTimeout(() => {
        this.hasError = false;
        this.errorMessage = '';
      }, 3000);
    },
  };
}

2. バリデーションエラーの処理:

html<form
  x-data="validationForm()"
  hx-post="/api/submit"
  hx-target="#result"
  @htmx:responseError="handleValidationError($event)"
>
  <div>
    <label for="email">メールアドレス:</label>
    <input
      type="email"
      id="email"
      name="email"
      :class="{ 'error': errors.email }"
      required
    />
    <span
      x-show="errors.email"
      x-text="errors.email"
      class="error-text"
    ></span>
  </div>

  <button type="submit">送信</button>
</form>

JavaScript 部分:

javascriptfunction validationForm() {
  return {
    errors: {},

    handleValidationError(event) {
      if (event.detail.xhr.status === 422) {
        // バリデーションエラーの場合
        const response = JSON.parse(
          event.detail.xhr.responseText
        );
        this.errors = response.errors || {};
      } else {
        // その他のエラー
        this.errors = { general: '送信に失敗しました。' };
      }
    },
  };
}

3. よくあるエラーと解決策:

エラー原因解決策
htmx is not definedhtmx ライブラリが読み込まれていないCDN の URL を確認、ネットワーク接続を確認
Alpine.js is not definedAlpine.js ライブラリが読み込まれていないdefer 属性の確認、読み込み順序の確認
hx-target not foundターゲット要素が存在しない要素の ID を確認、DOM の読み込みタイミングを確認
CORS errorクロスオリジンリクエストの問題サーバー側で CORS 設定を追加
x-data not workingAlpine.js の初期化タイミングの問題x-data の構文を確認、JavaScript エラーを確認

アクセシビリティへの配慮

すべてのユーザーが利用しやすいアプリケーションを作成するためのガイドラインです。

1. キーボードナビゲーション:

html<div
  x-data="keyboardNav()"
  @keydown.escape="closeModal()"
  @keydown.arrow-left="previousItem()"
  @keydown.arrow-right="nextItem()"
>
  <!-- フォーカス可能な要素に適切な tabindex を設定 -->
  <button
    tabindex="0"
    @click="openModal()"
    @keydown.enter="openModal()"
    @keydown.space.prevent="openModal()"
  >
    モーダルを開く
  </button>
</div>

2. スクリーンリーダー対応:

html<!-- ARIA 属性の適切な使用 -->
<div
  role="dialog"
  aria-labelledby="modal-title"
  aria-describedby="modal-description"
  x-show="isOpen"
>
  <h2 id="modal-title">モーダルタイトル</h2>
  <p id="modal-description">モーダルの説明文</p>

  <!-- フォーカス管理 -->
  <button
    @click="closeModal()"
    aria-label="モーダルを閉じる"
  >
    ×
  </button>
</div>

3. ローディング状態の表示:

html<!-- ローディング状態を適切に表示 -->
<button
  hx-get="/api/data"
  hx-target="#results"
  :disabled="isLoading"
  :aria-busy="isLoading"
>
  <span x-show="!isLoading">データを取得</span>
  <span x-show="isLoading" aria-live="polite"
    >読み込み中...</span
  >
</button>

まとめ

htmx と Alpine.js を組み合わせることで、従来の重厚なフレームワークに頼らずとも、高度なインタラクティブな UI を作成できることを学びました。

主なメリット:

  1. 学習コストの低さ: 既存の HTML と CSS の知識を活かせる
  2. 軽量性: バンドルサイズが小さく、初期読み込みが高速
  3. 段階的な導入: 既存のプロジェクトに部分的に導入可能
  4. サーバーサイドとの親和性: 既存のサーバーサイド技術と組み合わせやすい
  5. 開発効率: 複雑なビルドプロセスが不要

実践的なポイント:

  • 適切なエラーハンドリングを実装する
  • パフォーマンスを考慮した DOM 操作を行う
  • アクセシビリティに配慮した UI 設計を行う
  • 段階的に機能を追加していく

この記事で紹介した手法を活用することで、モダンで使いやすい Web アプリケーションを効率的に開発できるようになります。htmx と Alpine.js の組み合わせは、Web 開発の新しい可能性を開く強力なツールです。

関連リンク