T-CREATOR

htmx で管理画面 CRUD を 10 倍速に:一覧・検索・編集・バルク操作テンプレ

htmx で管理画面 CRUD を 10 倍速に:一覧・検索・編集・バルク操作テンプレ

管理画面の開発、もっとシンプルにできないかと思ったことはありませんか。React や Vue.js で状態管理を組み立てるのは強力ですが、単純な CRUD 操作のためだけに大量のボイラープレートを書くのは正直大変です。

そこで注目したいのが htmx です。htmx を使えば、JavaScript をほとんど書かずに、HTML 属性だけで動的な管理画面を実装できます。この記事では、一覧表示・検索・編集・バルク操作といった管理画面の定番機能を、htmx でどれだけ高速に実装できるかを具体的なコードとともにご紹介します。

背景

管理画面開発の課題

従来の管理画面開発では、フロントエンドとバックエンドを完全に分離し、REST API や GraphQL を介してデータをやり取りする SPA(Single Page Application)構成が主流でした。

mermaidflowchart TB
  browser["ブラウザ"]
  spa["React/Vue.js SPA"]
  api["REST API"]
  db[("データベース")]

  browser -->|初回ロード| spa
  spa -->|JSON リクエスト| api
  api -->|クエリ| db
  api -->|JSON レスポンス| spa
  spa -->|DOM 更新| browser

この構成には以下のような利点がありました。

  • フロントエンドとバックエンドの完全な分離
  • リッチな UI/UX の実現
  • モバイルアプリとの API 共有

しかし、管理画面のような CRUD 中心のシンプルな画面 においては、以下のような問題が発生します。

#課題具体例
1ボイラープレートの増加状態管理、API クライアント、型定義の重複
2バンドルサイズの肥大化React + Redux + Router で 300KB 超え
3開発速度の低下フロント・バックの二重実装が必要
4保守コストの増加フレームワークのバージョンアップ対応

htmx が解決するもの

htmx は HTML 属性だけで AJAX リクエストを実行 できる軽量ライブラリ(約 14KB)です。サーバーから返された HTML フラグメントを直接 DOM に挿入するため、JavaScript での状態管理やテンプレートレンダリングが不要になります。

mermaidflowchart TB
  browser["ブラウザ"]
  html["HTML + htmx 属性"]
  server["サーバー(Node.js/Express)"]
  db[("データベース")]

  browser -->|初回ロード| html
  html -->|HTML リクエスト| server
  server -->|クエリ| db
  server -->|HTML フラグメント| html
  html -->|DOM 差し替え| browser

htmx を使うことで、以下のメリットが得られます。

  • JavaScript コードの大幅削減
  • サーバーサイドで HTML を生成するシンプルな構成
  • 段階的な導入が可能(既存の HTML に属性を追加するだけ)

課題

管理画面で必要な機能

一般的な管理画面では、以下の機能が必須です。

mermaidflowchart LR
  list["一覧表示"]
  search["検索・フィルター"]
  edit["編集"]
  bulk["バルク操作"]

  list --> search
  search --> edit
  edit --> bulk

  style list fill:#e1f5ff
  style search fill:#fff4e1
  style edit fill:#ffe1f5
  style bulk fill:#e1ffe1

それぞれの機能には、以下のような課題があります。

#機能従来の実装の課題
1一覧表示ページネーション、ソート、無限スクロールの実装が複雑
2検索・フィルター入力のたびに API を叩く処理とデバウンス実装が必要
3編集インライン編集時の状態管理が煩雑
4バルク操作チェックボックスの状態管理と一括処理の実装

SPA で実装した場合のコード量

例えば、React で一覧表示とページネーションを実装する場合、以下のようなコードが必要です。

  • useState でページ状態を管理
  • useEffect で API 呼び出し
  • ページネーションコンポーネントの実装
  • ローディング状態の管理

結果として、数百行のコード が必要になることも珍しくありません。

htmx ではこれらを HTML 属性のみ で実装でき、コード量を 10 分の 1 以下に削減できます。

解決策

htmx の基本的な使い方

htmx は、HTML 要素に特定の属性を追加するだけで AJAX リクエストを発行できます。

基本属性

主要な htmx 属性は以下の通りです。

#属性説明
1hx-getGET リクエストを送信hx-get="​/​api​/​users"
2hx-postPOST リクエストを送信hx-post="​/​api​/​users"
3hx-targetレスポンスを挿入する要素hx-target="#result"
4hx-swap挿入方法の指定hx-swap="innerHTML"
5hx-triggerトリガーイベントhx-trigger="keyup changed delay:500ms"

基本的な例

以下は、ボタンクリックでユーザー一覧を取得する最小限の例です。

html<!-- ボタン要素 -->
<button
  hx-get="/api/users"
  hx-target="#user-list"
  hx-swap="innerHTML"
>
  ユーザー一覧を読み込む
</button>

<!-- 結果を表示する領域 -->
<div id="user-list"></div>

このボタンをクリックすると、​/​api​/​users に GET リクエストが送信され、返された HTML が #user-list 要素の innerHTML として挿入されます。

サーバー側の実装

サーバー側では、HTML フラグメントを返すだけです。

javascript// Express での実装例
app.get('/api/users', async (req, res) => {
  const users = await db.query(
    'SELECT * FROM users LIMIT 10'
  );

  // HTML フラグメントを生成して返す
  const html = users
    .map(
      (user) => `
    <div class="user-item">
      <span>${user.name}</span>
      <span>${user.email}</span>
    </div>
  `
    )
    .join('');

  res.send(html);
});

この構成により、JavaScript での状態管理やテンプレートレンダリングが不要になります。

htmx の設定と初期化

htmx を使い始めるには、CDN から読み込むか、npm でインストールします。

CDN での読み込み

html<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>管理画面</title>
    <!-- htmx を CDN から読み込み -->
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
  </head>
  <body>
    <!-- ここにコンテンツ -->
  </body>
</html>

npm でのインストール

bashyarn add htmx.org
javascript// JavaScript ファイルでインポート
import 'htmx.org';

htmx は script タグで読み込むだけで自動的に初期化され、hx-* 属性を持つ要素を検出して動作します。追加の設定は不要です。

具体例

ここからは、管理画面の各機能を htmx で実装する具体例をご紹介します。

1. 一覧表示とページネーション

HTML 側の実装

一覧表示では、テーブルとページネーションボタンを配置します。

html<!-- ユーザー一覧テーブル -->
<div id="user-table-container">
  <table class="table">
    <thead>
      <tr>
        <th>ID</th>
        <th>名前</th>
        <th>メール</th>
        <th>登録日</th>
        <th>操作</th>
      </tr>
    </thead>
    <tbody id="user-list">
      <!-- サーバーから返される HTML がここに挿入される -->
    </tbody>
  </table>
</div>

初回ロード時にデータを取得する場合は、hx-trigger="load" を使います。

html<!-- ページ読み込み時に自動的にデータを取得 -->
<tbody
  id="user-list"
  hx-get="/api/users?page=1"
  hx-trigger="load"
  hx-swap="innerHTML"
>
  <tr>
    <td colspan="5">読み込み中...</td>
  </tr>
</tbody>

ページネーションの実装

ページネーションボタンには、hx-get でページ番号を指定します。

html<!-- ページネーションコントロール -->
<div id="pagination" class="pagination">
  <button
    hx-get="/api/users?page=1"
    hx-target="#user-list"
    hx-swap="innerHTML"
  >
    1
  </button>
  <button
    hx-get="/api/users?page=2"
    hx-target="#user-list"
    hx-swap="innerHTML"
  >
    2
  </button>
  <button
    hx-get="/api/users?page=3"
    hx-target="#user-list"
    hx-swap="innerHTML"
  >
    3
  </button>
</div>

サーバー側の実装(Express + TypeScript)

サーバー側では、ページ番号に応じたデータを取得し、HTML を生成します。

typescriptimport express, { Request, Response } from 'express';
import { Pool } from 'pg';

const app = express();
const db = new Pool({
  host: 'localhost',
  database: 'admin_db',
  user: 'admin',
  password: 'password',
});
typescript// ページあたりの件数
const ITEMS_PER_PAGE = 10;

// ユーザー一覧の型定義
interface User {
  id: number;
  name: string;
  email: string;
  created_at: Date;
}
typescript// ユーザー一覧 API
app.get(
  '/api/users',
  async (req: Request, res: Response) => {
    // ページ番号を取得(デフォルトは 1)
    const page = parseInt(req.query.page as string) || 1;
    const offset = (page - 1) * ITEMS_PER_PAGE;

    try {
      // データベースからユーザーを取得
      const result = await db.query<User>(
        'SELECT id, name, email, created_at FROM users ORDER BY id LIMIT $1 OFFSET $2',
        [ITEMS_PER_PAGE, offset]
      );

      // HTML フラグメントを生成
      const html = result.rows
        .map(
          (user) => `
      <tr>
        <td>${user.id}</td>
        <td>${user.name}</td>
        <td>${user.email}</td>
        <td>${new Date(user.created_at).toLocaleDateString(
          'ja-JP'
        )}</td>
        <td>
          <button
            hx-get="/api/users/${user.id}/edit"
            hx-target="#edit-modal"
            hx-swap="innerHTML">
            編集
          </button>
        </td>
      </tr>
    `
        )
        .join('');

      res.send(html);
    } catch (error) {
      res
        .status(500)
        .send(
          '<tr><td colspan="5">エラーが発生しました</td></tr>'
        );
    }
  }
);

このコードでは、クエリパラメータから page を取得し、OFFSET を計算してデータベースから該当ページのデータを取得しています。返される HTML は <tr> 要素の集合で、#user-listtbody に直接挿入されます。

2. 検索・フィルター機能

リアルタイム検索の実装

検索機能では、入力フィールドに hx-gethx-trigger を組み合わせます。

html<!-- 検索フォーム -->
<div class="search-box">
  <input
    type="text"
    name="q"
    placeholder="名前またはメールで検索..."
    hx-get="/api/users/search"
    hx-target="#user-list"
    hx-trigger="keyup changed delay:500ms"
    hx-include="[name='status']"
    autocomplete="off"
  />
</div>

hx-trigger="keyup changed delay:500ms" により、キー入力から 500ms 後に自動的にリクエストが送信されます。これにより、ユーザーが入力を完了するまで待機し、不要なリクエストを削減できます。

フィルター選択肢の追加

ステータスなどのフィルター条件も追加できます。

html<!-- ステータスフィルター -->
<select
  name="status"
  hx-get="/api/users/search"
  hx-target="#user-list"
  hx-trigger="change"
  hx-include="[name='q']"
>
  <option value="">すべて</option>
  <option value="active">アクティブ</option>
  <option value="inactive">非アクティブ</option>
</select>

hx-include="[name='q']" により、検索テキストボックスの値も一緒に送信されます。

サーバー側の検索実装

サーバー側では、クエリパラメータを受け取って検索を実行します。

typescript// 検索 API
app.get(
  '/api/users/search',
  async (req: Request, res: Response) => {
    const searchQuery = (req.query.q as string) || '';
    const status = (req.query.status as string) || '';

    try {
      // SQL クエリの構築
      let sql =
        'SELECT id, name, email, created_at, status FROM users WHERE 1=1';
      const params: any[] = [];
      let paramIndex = 1;

      // 検索クエリの追加
      if (searchQuery) {
        sql += ` AND (name ILIKE $${paramIndex} OR email ILIKE $${paramIndex})`;
        params.push(`%${searchQuery}%`);
        paramIndex++;
      }

      // ステータスフィルターの追加
      if (status) {
        sql += ` AND status = $${paramIndex}`;
        params.push(status);
        paramIndex++;
      }

      sql += ' ORDER BY id LIMIT 10';

      const result = await db.query<User>(sql, params);

      // 結果が 0 件の場合
      if (result.rows.length === 0) {
        res.send(
          '<tr><td colspan="5">該当するユーザーが見つかりませんでした</td></tr>'
        );
        return;
      }

      // HTML フラグメントを生成
      const html = result.rows
        .map(
          (user) => `
      <tr>
        <td>${user.id}</td>
        <td>${user.name}</td>
        <td>${user.email}</td>
        <td>${new Date(user.created_at).toLocaleDateString(
          'ja-JP'
        )}</td>
        <td>
          <button
            hx-get="/api/users/${user.id}/edit"
            hx-target="#edit-modal"
            hx-swap="innerHTML">
            編集
          </button>
        </td>
      </tr>
    `
        )
        .join('');

      res.send(html);
    } catch (error) {
      res
        .status(500)
        .send(
          '<tr><td colspan="5">検索中にエラーが発生しました</td></tr>'
        );
    }
  }
);

このコードでは、検索クエリとステータスの両方を考慮した動的な SQL を構築しています。パラメータ化されたクエリを使用することで、SQL インジェクション攻撃を防いでいます。

3. インライン編集機能

編集モードの切り替え

インライン編集では、表示モードと編集モードを切り替えます。

html<!-- 表示モード(初期状態) -->
<tr id="user-row-123">
  <td>123</td>
  <td id="user-name-123">山田太郎</td>
  <td id="user-email-123">yamada@example.com</td>
  <td>2024/01/15</td>
  <td>
    <button
      hx-get="/api/users/123/edit-form"
      hx-target="#user-row-123"
      hx-swap="outerHTML"
    >
      編集
    </button>
  </td>
</tr>

編集ボタンをクリックすると、行全体が編集フォームに置き換わります。

編集フォームの返却

サーバー側では、編集フォームを含む <tr> を返します。

typescript// 編集フォーム取得 API
app.get(
  '/api/users/:id/edit-form',
  async (req: Request, res: Response) => {
    const userId = parseInt(req.params.id);

    try {
      const result = await db.query<User>(
        'SELECT id, name, email, created_at FROM users WHERE id = $1',
        [userId]
      );

      if (result.rows.length === 0) {
        res
          .status(404)
          .send(
            '<tr><td colspan="5">ユーザーが見つかりません</td></tr>'
          );
        return;
      }

      const user = result.rows[0];

      // 編集フォームを含む tr を返す
      const html = `
      <tr id="user-row-${user.id}">
        <td>${user.id}</td>
        <td>
          <input type="text" name="name" value="${
            user.name
          }" class="form-control">
        </td>
        <td>
          <input type="email" name="email" value="${
            user.email
          }" class="form-control">
        </td>
        <td>${new Date(user.created_at).toLocaleDateString(
          'ja-JP'
        )}</td>
        <td>
          <button
            hx-put="/api/users/${user.id}"
            hx-include="closest tr"
            hx-target="#user-row-${user.id}"
            hx-swap="outerHTML">
            保存
          </button>
          <button
            hx-get="/api/users/${user.id}/cancel-edit"
            hx-target="#user-row-${user.id}"
            hx-swap="outerHTML">
            キャンセル
          </button>
        </td>
      </tr>
    `;

      res.send(html);
    } catch (error) {
      res
        .status(500)
        .send(
          '<tr><td colspan="5">エラーが発生しました</td></tr>'
        );
    }
  }
);

hx-include="closest tr" により、最も近い <tr> 要素内のすべての input 値が送信されます。

更新処理の実装

保存ボタンがクリックされると、PUT リクエストでデータを更新します。

typescript// ユーザー更新 API
app.put(
  '/api/users/:id',
  express.urlencoded({ extended: true }),
  async (req: Request, res: Response) => {
    const userId = parseInt(req.params.id);
    const { name, email } = req.body;

    try {
      // バリデーション
      if (!name || !email) {
        res
          .status(400)
          .send(
            '<tr><td colspan="5">名前とメールアドレスは必須です</td></tr>'
          );
        return;
      }

      // データベース更新
      await db.query(
        'UPDATE users SET name = $1, email = $2 WHERE id = $3',
        [name, email, userId]
      );

      // 更新後のデータを取得
      const result = await db.query<User>(
        'SELECT id, name, email, created_at FROM users WHERE id = $1',
        [userId]
      );

      const user = result.rows[0];

      // 表示モードの HTML を返す
      const html = `
      <tr id="user-row-${user.id}">
        <td>${user.id}</td>
        <td>${user.name}</td>
        <td>${user.email}</td>
        <td>${new Date(user.created_at).toLocaleDateString(
          'ja-JP'
        )}</td>
        <td>
          <button
            hx-get="/api/users/${user.id}/edit-form"
            hx-target="#user-row-${user.id}"
            hx-swap="outerHTML">
            編集
          </button>
        </td>
      </tr>
    `;

      res.send(html);
    } catch (error) {
      res
        .status(500)
        .send(
          '<tr><td colspan="5">更新中にエラーが発生しました</td></tr>'
        );
    }
  }
);

更新が成功すると、編集フォームが表示モードに戻ります。キャンセルボタンの実装も同様に、表示モードの HTML を返すだけです。

4. バルク操作(一括削除・一括更新)

チェックボックスの実装

バルク操作では、各行にチェックボックスを配置します。

html<!-- テーブルヘッダー -->
<thead>
  <tr>
    <th>
      <input
        type="checkbox"
        id="select-all"
        onclick="document.querySelectorAll('.user-checkbox').forEach(cb => cb.checked = this.checked)"
      />
    </th>
    <th>ID</th>
    <th>名前</th>
    <th>メール</th>
    <th>登録日</th>
  </tr>
</thead>

各行のチェックボックスには、ユーザー ID を値として設定します。

html<!-- データ行(サーバーから返される HTML) -->
<tr>
  <td>
    <input
      type="checkbox"
      class="user-checkbox"
      name="user_ids"
      value="123"
    />
  </td>
  <td>123</td>
  <td>山田太郎</td>
  <td>yamada@example.com</td>
  <td>2024/01/15</td>
</tr>

バルク操作ボタンの実装

選択された項目に対して一括操作を実行するボタンを配置します。

html<!-- バルク操作コントロール -->
<div class="bulk-actions">
  <button
    hx-delete="/api/users/bulk-delete"
    hx-include="[name='user_ids']:checked"
    hx-target="#user-list"
    hx-confirm="選択したユーザーを削除してもよろしいですか?"
    class="btn btn-danger"
  >
    選択したユーザーを削除
  </button>

  <button
    hx-post="/api/users/bulk-activate"
    hx-include="[name='user_ids']:checked"
    hx-target="#user-list"
    class="btn btn-success"
  >
    選択したユーザーを有効化
  </button>
</div>

hx-include="[name='user_ids']:checked" により、チェックされたチェックボックスの値のみが送信されます。hx-confirm 属性により、実行前に確認ダイアログが表示されます。

サーバー側のバルク削除実装

サーバー側では、複数の ID を受け取って一括処理を実行します。

typescript// バルク削除 API
app.delete(
  '/api/users/bulk-delete',
  express.urlencoded({ extended: true }),
  async (req: Request, res: Response) => {
    // チェックボックスの値を配列として取得
    let userIds = req.body.user_ids;

    // 単一の値の場合は配列に変換
    if (!Array.isArray(userIds)) {
      userIds = userIds ? [userIds] : [];
    }

    // バリデーション
    if (userIds.length === 0) {
      res
        .status(400)
        .send(
          '<tr><td colspan="5">削除するユーザーを選択してください</td></tr>'
        );
      return;
    }

    try {
      // 数値の配列に変換
      const ids = userIds.map((id) => parseInt(id));

      // バルク削除を実行
      await db.query(
        'DELETE FROM users WHERE id = ANY($1)',
        [ids]
      );

      // 削除後の一覧を取得して返す
      const result = await db.query<User>(
        'SELECT id, name, email, created_at FROM users ORDER BY id LIMIT 10'
      );

      const html = result.rows
        .map(
          (user) => `
      <tr>
        <td>
          <input type="checkbox" class="user-checkbox" name="user_ids" value="${
            user.id
          }">
        </td>
        <td>${user.id}</td>
        <td>${user.name}</td>
        <td>${user.email}</td>
        <td>${new Date(user.created_at).toLocaleDateString(
          'ja-JP'
        )}</td>
      </tr>
    `
        )
        .join('');

      res.send(html);
    } catch (error) {
      res
        .status(500)
        .send(
          '<tr><td colspan="5">削除中にエラーが発生しました</td></tr>'
        );
    }
  }
);

PostgreSQL の ANY 演算子を使用して、配列内の ID に一致するすべての行を削除しています。

バルク更新の実装

ステータスの一括更新も同様に実装できます。

typescript// バルク有効化 API
app.post(
  '/api/users/bulk-activate',
  express.urlencoded({ extended: true }),
  async (req: Request, res: Response) => {
    let userIds = req.body.user_ids;

    if (!Array.isArray(userIds)) {
      userIds = userIds ? [userIds] : [];
    }

    if (userIds.length === 0) {
      res
        .status(400)
        .send(
          '<tr><td colspan="5">有効化するユーザーを選択してください</td></tr>'
        );
      return;
    }

    try {
      const ids = userIds.map((id) => parseInt(id));

      // ステータスを一括更新
      await db.query(
        'UPDATE users SET status = $1 WHERE id = ANY($2)',
        ['active', ids]
      );

      // 更新後の一覧を取得して返す
      const result = await db.query<User>(
        'SELECT id, name, email, created_at, status FROM users ORDER BY id LIMIT 10'
      );

      const html = result.rows
        .map(
          (user) => `
      <tr>
        <td>
          <input type="checkbox" class="user-checkbox" name="user_ids" value="${
            user.id
          }">
        </td>
        <td>${user.id}</td>
        <td>${user.name}</td>
        <td>${user.email}</td>
        <td>${new Date(user.created_at).toLocaleDateString(
          'ja-JP'
        )}</td>
        <td>
          <span class="badge ${
            user.status === 'active'
              ? 'badge-success'
              : 'badge-secondary'
          }">
            ${
              user.status === 'active'
                ? 'アクティブ'
                : '非アクティブ'
            }
          </span>
        </td>
      </tr>
    `
        )
        .join('');

      res.send(html);
    } catch (error) {
      res
        .status(500)
        .send(
          '<tr><td colspan="5">更新中にエラーが発生しました</td></tr>'
        );
    }
  }
);

5. ローディング表示とエラーハンドリング

ローディングインジケーターの実装

htmx には、リクエスト中に自動的にクラスを追加する機能があります。

html<!-- ローディング表示用のスタイル -->
<style>
  .htmx-request {
    opacity: 0.5;
    pointer-events: none;
  }

  .htmx-request::after {
    content: '読み込み中...';
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background: white;
    padding: 10px 20px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }
</style>

htmx は、リクエスト中の要素に自動的に htmx-request クラスを追加します。追加の JavaScript は不要です。

カスタムローディング表示

特定の要素にローディング表示を出したい場合は、hx-indicator 属性を使用します。

html<!-- ローディングスピナー -->
<div
  id="loading-spinner"
  class="spinner"
  style="display: none;"
>
  <div class="spinner-border" role="status">
    <span class="sr-only">読み込み中...</span>
  </div>
</div>

<!-- ボタンにローディングインジケーターを指定 -->
<button
  hx-get="/api/users?page=1"
  hx-target="#user-list"
  hx-indicator="#loading-spinner"
>
  データを読み込む
</button>
css/* hx-indicator で指定された要素は、リクエスト中に自動的に表示される */
.htmx-request #loading-spinner {
  display: block !important;
}

エラーハンドリング

エラー時には、htmx が自動的に htmx-swapping:abort イベントを発行します。これをリスナーで捕捉できます。

javascript// グローバルエラーハンドリング
document.body.addEventListener(
  'htmx:responseError',
  function (event) {
    // エラーレスポンスの処理
    const target = event.detail.target;

    if (event.detail.xhr.status === 500) {
      target.innerHTML =
        '<div class="alert alert-danger">サーバーエラーが発生しました</div>';
    } else if (event.detail.xhr.status === 404) {
      target.innerHTML =
        '<div class="alert alert-warning">データが見つかりませんでした</div>';
    }
  }
);

この JavaScript コードは、htmx が発行するイベントをリスナーで受け取り、ステータスコードに応じたエラーメッセージを表示します。

6. 完全な実装例

ここまでの内容を統合した、完全な管理画面の実装例です。

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>

    <!-- Bootstrap CSS -->
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
      rel="stylesheet"
    />

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

    <style>
      .htmx-request {
        opacity: 0.6;
      }
      .search-box {
        margin-bottom: 20px;
      }
      .bulk-actions {
        margin-bottom: 15px;
      }
    </style>
  </head>
  <body>
    <div class="container mt-5">
      <h1 class="mb-4">ユーザー管理</h1>

      <!-- 検索・フィルター -->
      <div class="row search-box">
        <div class="col-md-6">
          <input
            type="text"
            name="q"
            class="form-control"
            placeholder="名前またはメールで検索..."
            hx-get="/api/users/search"
            hx-target="#user-list"
            hx-trigger="keyup changed delay:500ms"
            hx-include="[name='status']"
            autocomplete="off"
          />
        </div>
        <div class="col-md-3">
          <select
            name="status"
            class="form-select"
            hx-get="/api/users/search"
            hx-target="#user-list"
            hx-trigger="change"
            hx-include="[name='q']"
          >
            <option value="">すべて</option>
            <option value="active">アクティブ</option>
            <option value="inactive">非アクティブ</option>
          </select>
        </div>
      </div>

      <!-- バルク操作 -->
      <div class="bulk-actions">
        <button
          hx-delete="/api/users/bulk-delete"
          hx-include="[name='user_ids']:checked"
          hx-target="#user-list"
          hx-confirm="選択したユーザーを削除してもよろしいですか?"
          class="btn btn-danger btn-sm"
        >
          選択を削除
        </button>
        <button
          hx-post="/api/users/bulk-activate"
          hx-include="[name='user_ids']:checked"
          hx-target="#user-list"
          class="btn btn-success btn-sm"
        >
          選択を有効化
        </button>
      </div>

      <!-- ユーザー一覧テーブル -->
      <div class="table-responsive">
        <table class="table table-striped">
          <thead>
            <tr>
              <th>
                <input
                  type="checkbox"
                  id="select-all"
                  onclick="document.querySelectorAll('.user-checkbox').forEach(cb => cb.checked = this.checked)"
                />
              </th>
              <th>ID</th>
              <th>名前</th>
              <th>メール</th>
              <th>ステータス</th>
              <th>登録日</th>
              <th>操作</th>
            </tr>
          </thead>
          <tbody
            id="user-list"
            hx-get="/api/users?page=1"
            hx-trigger="load"
            hx-swap="innerHTML"
          >
            <tr>
              <td colspan="7" class="text-center">
                読み込み中...
              </td>
            </tr>
          </tbody>
        </table>
      </div>

      <!-- ページネーション -->
      <nav
        id="pagination-container"
        hx-get="/api/users/pagination?page=1"
        hx-trigger="load"
        hx-swap="innerHTML"
      ></nav>
    </div>

    <!-- エラーハンドリング -->
    <script>
      document.body.addEventListener(
        'htmx:responseError',
        function (event) {
          const target = event.detail.target;
          if (event.detail.xhr.status === 500) {
            target.innerHTML =
              '<tr><td colspan="7" class="text-center text-danger">サーバーエラーが発生しました</td></tr>';
          }
        }
      );
    </script>
  </body>
</html>

この HTML ファイルには、検索、フィルター、バルク操作、ページネーションのすべての機能が含まれています。htmx の属性により、JavaScript をほとんど書かずに動的な操作が可能です。

サーバー側の完全な実装

typescriptimport express, { Request, Response } from 'express';
import { Pool } from 'pg';

const app = express();
const PORT = 3000;

// ミドルウェア
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(express.static('public'));

// データベース接続
const db = new Pool({
  host: 'localhost',
  database: 'admin_db',
  user: 'admin',
  password: 'password',
  port: 5432,
});

const ITEMS_PER_PAGE = 10;
typescript// 型定義
interface User {
  id: number;
  name: string;
  email: string;
  status: 'active' | 'inactive';
  created_at: Date;
}

// ユーザー行の HTML を生成するヘルパー関数
function renderUserRow(user: User): string {
  const statusBadge =
    user.status === 'active'
      ? '<span class="badge bg-success">アクティブ</span>'
      : '<span class="badge bg-secondary">非アクティブ</span>';

  return `
    <tr id="user-row-${user.id}">
      <td>
        <input type="checkbox" class="user-checkbox" name="user_ids" value="${
          user.id
        }">
      </td>
      <td>${user.id}</td>
      <td>${user.name}</td>
      <td>${user.email}</td>
      <td>${statusBadge}</td>
      <td>${new Date(user.created_at).toLocaleDateString(
        'ja-JP'
      )}</td>
      <td>
        <button
          hx-get="/api/users/${user.id}/edit-form"
          hx-target="#user-row-${user.id}"
          hx-swap="outerHTML"
          class="btn btn-sm btn-primary">
          編集
        </button>
      </td>
    </tr>
  `;
}
typescript// ユーザー一覧取得
app.get(
  '/api/users',
  async (req: Request, res: Response) => {
    const page = parseInt(req.query.page as string) || 1;
    const offset = (page - 1) * ITEMS_PER_PAGE;

    try {
      const result = await db.query<User>(
        'SELECT id, name, email, status, created_at FROM users ORDER BY id LIMIT $1 OFFSET $2',
        [ITEMS_PER_PAGE, offset]
      );

      const html = result.rows.map(renderUserRow).join('');
      res.send(html);
    } catch (error) {
      console.error(error);
      res
        .status(500)
        .send(
          '<tr><td colspan="7" class="text-center text-danger">エラーが発生しました</td></tr>'
        );
    }
  }
);
typescript// 検索・フィルター
app.get(
  '/api/users/search',
  async (req: Request, res: Response) => {
    const searchQuery = (req.query.q as string) || '';
    const status = (req.query.status as string) || '';

    try {
      let sql =
        'SELECT id, name, email, status, created_at FROM users WHERE 1=1';
      const params: any[] = [];
      let paramIndex = 1;

      if (searchQuery) {
        sql += ` AND (name ILIKE $${paramIndex} OR email ILIKE $${paramIndex})`;
        params.push(`%${searchQuery}%`);
        paramIndex++;
      }

      if (status) {
        sql += ` AND status = $${paramIndex}`;
        params.push(status);
        paramIndex++;
      }

      sql += ` ORDER BY id LIMIT ${ITEMS_PER_PAGE}`;

      const result = await db.query<User>(sql, params);

      if (result.rows.length === 0) {
        res.send(
          '<tr><td colspan="7" class="text-center">該当するユーザーが見つかりませんでした</td></tr>'
        );
        return;
      }

      const html = result.rows.map(renderUserRow).join('');
      res.send(html);
    } catch (error) {
      console.error(error);
      res
        .status(500)
        .send(
          '<tr><td colspan="7" class="text-center text-danger">検索中にエラーが発生しました</td></tr>'
        );
    }
  }
);
typescript// 編集フォーム取得
app.get(
  '/api/users/:id/edit-form',
  async (req: Request, res: Response) => {
    const userId = parseInt(req.params.id);

    try {
      const result = await db.query<User>(
        'SELECT id, name, email, status, created_at FROM users WHERE id = $1',
        [userId]
      );

      if (result.rows.length === 0) {
        res
          .status(404)
          .send(
            '<tr><td colspan="7" class="text-center">ユーザーが見つかりません</td></tr>'
          );
        return;
      }

      const user = result.rows[0];

      const html = `
      <tr id="user-row-${user.id}">
        <td>
          <input type="checkbox" class="user-checkbox" name="user_ids" value="${
            user.id
          }" disabled>
        </td>
        <td>${user.id}</td>
        <td>
          <input type="text" name="name" value="${
            user.name
          }" class="form-control form-control-sm">
        </td>
        <td>
          <input type="email" name="email" value="${
            user.email
          }" class="form-control form-control-sm">
        </td>
        <td>
          <select name="status" class="form-select form-select-sm">
            <option value="active" ${
              user.status === 'active' ? 'selected' : ''
            }>アクティブ</option>
            <option value="inactive" ${
              user.status === 'inactive' ? 'selected' : ''
            }>非アクティブ</option>
          </select>
        </td>
        <td>${new Date(user.created_at).toLocaleDateString(
          'ja-JP'
        )}</td>
        <td>
          <button
            hx-put="/api/users/${user.id}"
            hx-include="closest tr"
            hx-target="#user-row-${user.id}"
            hx-swap="outerHTML"
            class="btn btn-sm btn-success">
            保存
          </button>
          <button
            hx-get="/api/users/${user.id}/cancel-edit"
            hx-target="#user-row-${user.id}"
            hx-swap="outerHTML"
            class="btn btn-sm btn-secondary">
            キャンセル
          </button>
        </td>
      </tr>
    `;

      res.send(html);
    } catch (error) {
      console.error(error);
      res
        .status(500)
        .send(
          '<tr><td colspan="7" class="text-center text-danger">エラーが発生しました</td></tr>'
        );
    }
  }
);
typescript// ユーザー更新
app.put(
  '/api/users/:id',
  async (req: Request, res: Response) => {
    const userId = parseInt(req.params.id);
    const { name, email, status } = req.body;

    try {
      if (!name || !email) {
        res
          .status(400)
          .send(
            '<tr><td colspan="7" class="text-center text-danger">名前とメールアドレスは必須です</td></tr>'
          );
        return;
      }

      await db.query(
        'UPDATE users SET name = $1, email = $2, status = $3 WHERE id = $4',
        [name, email, status, userId]
      );

      const result = await db.query<User>(
        'SELECT id, name, email, status, created_at FROM users WHERE id = $1',
        [userId]
      );

      const html = renderUserRow(result.rows[0]);
      res.send(html);
    } catch (error) {
      console.error(error);
      res
        .status(500)
        .send(
          '<tr><td colspan="7" class="text-center text-danger">更新中にエラーが発生しました</td></tr>'
        );
    }
  }
);
typescript// 編集キャンセル
app.get(
  '/api/users/:id/cancel-edit',
  async (req: Request, res: Response) => {
    const userId = parseInt(req.params.id);

    try {
      const result = await db.query<User>(
        'SELECT id, name, email, status, created_at FROM users WHERE id = $1',
        [userId]
      );

      const html = renderUserRow(result.rows[0]);
      res.send(html);
    } catch (error) {
      console.error(error);
      res
        .status(500)
        .send(
          '<tr><td colspan="7" class="text-center text-danger">エラーが発生しました</td></tr>'
        );
    }
  }
);
typescript// バルク削除
app.delete(
  '/api/users/bulk-delete',
  async (req: Request, res: Response) => {
    let userIds = req.body.user_ids;

    if (!Array.isArray(userIds)) {
      userIds = userIds ? [userIds] : [];
    }

    if (userIds.length === 0) {
      res
        .status(400)
        .send(
          '<tr><td colspan="7" class="text-center text-warning">削除するユーザーを選択してください</td></tr>'
        );
      return;
    }

    try {
      const ids = userIds.map((id) => parseInt(id));

      await db.query(
        'DELETE FROM users WHERE id = ANY($1)',
        [ids]
      );

      const result = await db.query<User>(
        'SELECT id, name, email, status, created_at FROM users ORDER BY id LIMIT $1',
        [ITEMS_PER_PAGE]
      );

      const html = result.rows.map(renderUserRow).join('');
      res.send(html);
    } catch (error) {
      console.error(error);
      res
        .status(500)
        .send(
          '<tr><td colspan="7" class="text-center text-danger">削除中にエラーが発生しました</td></tr>'
        );
    }
  }
);
typescript// バルク有効化
app.post(
  '/api/users/bulk-activate',
  async (req: Request, res: Response) => {
    let userIds = req.body.user_ids;

    if (!Array.isArray(userIds)) {
      userIds = userIds ? [userIds] : [];
    }

    if (userIds.length === 0) {
      res
        .status(400)
        .send(
          '<tr><td colspan="7" class="text-center text-warning">有効化するユーザーを選択してください</td></tr>'
        );
      return;
    }

    try {
      const ids = userIds.map((id) => parseInt(id));

      await db.query(
        'UPDATE users SET status = $1 WHERE id = ANY($2)',
        ['active', ids]
      );

      const result = await db.query<User>(
        'SELECT id, name, email, status, created_at FROM users ORDER BY id LIMIT $1',
        [ITEMS_PER_PAGE]
      );

      const html = result.rows.map(renderUserRow).join('');
      res.send(html);
    } catch (error) {
      console.error(error);
      res
        .status(500)
        .send(
          '<tr><td colspan="7" class="text-center text-danger">更新中にエラーが発生しました</td></tr>'
        );
    }
  }
);
typescript// ページネーション HTML 生成
app.get(
  '/api/users/pagination',
  async (req: Request, res: Response) => {
    try {
      const countResult = await db.query(
        'SELECT COUNT(*) as total FROM users'
      );
      const total = parseInt(countResult.rows[0].total);
      const totalPages = Math.ceil(total / ITEMS_PER_PAGE);

      let html = '<nav><ul class="pagination">';

      for (let i = 1; i <= totalPages; i++) {
        html += `
        <li class="page-item">
          <button
            class="page-link"
            hx-get="/api/users?page=${i}"
            hx-target="#user-list"
            hx-swap="innerHTML">
            ${i}
          </button>
        </li>
      `;
      }

      html += '</ul></nav>';
      res.send(html);
    } catch (error) {
      console.error(error);
      res.status(500).send('');
    }
  }
);
typescript// サーバー起動
app.listen(PORT, () => {
  console.log(
    `Server is running on http://localhost:${PORT}`
  );
});

このサーバー実装により、すべての CRUD 操作が HTML フラグメントの送受信だけで完結します。JSON API を作る必要がなく、フロントエンド側の状態管理も不要です。

まとめ

htmx を使った管理画面開発は、従来の SPA アプローチと比べて圧倒的にシンプルです。この記事でご紹介した内容をまとめます。

htmx の主なメリット

#メリット効果
1JavaScript コードの削減数百行 → 数十行に削減可能
2バンドルサイズの削減300KB → 14KB に削減
3開発速度の向上HTML 属性だけで動的機能を実装
4保守性の向上サーバー側で完結するシンプルな構成

実装した機能のおさらい

この記事では、以下の管理画面の定番機能を htmx で実装しました。

  • 一覧表示: hx-gethx-trigger="load" による自動読み込み
  • ページネーション: ボタンに hx-get を設定するだけで実装
  • 検索機能: hx-trigger="keyup changed delay:500ms" によるリアルタイム検索
  • フィルター: hx-include による複数パラメータの送信
  • インライン編集: hx-swap="outerHTML" による行の置き換え
  • バルク操作: hx-include="[name='user_ids']:checked" によるチェックボックス連携

どんな場合に htmx が適しているか

htmx は以下のようなケースで特に効果を発揮します。

  • 管理画面やダッシュボードなどの CRUD 中心のアプリケーション
  • 既存のサーバーサイドレンダリングアプリに動的機能を追加したい場合
  • JavaScript フレームワークの学習コストを避けたい場合
  • バンドルサイズを最小限に抑えたい場合

逆に、以下のような場合は React や Vue.js などのフレームワークが適しています。

  • 複雑な状態管理が必要な場合
  • リアルタイム性が重要な場合(チャット、コラボレーションツールなど)
  • モバイルアプリと API を共有する必要がある場合

次のステップ

htmx をさらに活用するには、以下の拡張機能やテクニックも検討してみてください。

  • htmx 拡張: hx-boost によるサイト全体の SPA 化
  • アニメーション: CSS トランジションとの組み合わせ
  • バリデーション: hx-validate による入力検証
  • WebSocket 対応: hx-ws によるリアルタイム通信

htmx は、モダンな Web 開発の新しい選択肢として、ますます注目を集めています。シンプルで高速な管理画面開発を実現したい方は、ぜひ試してみてください。

関連リンク