T-CREATOR

htmx で実装するリアルタイム検索とオートコンプリート

htmx で実装するリアルタイム検索とオートコンプリート

現代の Web アプリケーションにおいて、ユーザーが求める情報を素早く見つけることは、ユーザー体験の向上に直結します。従来の検索機能では、ユーザーが検索ボタンを押すまで結果が表示されず、何度も試行錯誤を繰り返す必要がありました。

htmx を使ったリアルタイム検索とオートコンプリート機能を実装することで、ユーザーが入力するたびに即座に結果が更新され、検索候補も表示されるようになります。これにより、ユーザーは直感的に情報を見つけることができ、アプリケーションへの満足度が大幅に向上するでしょう。

この記事では、htmx の特性を活かした効率的な検索機能の実装方法を、段階的に解説していきます。JavaScript フレームワークを使わずに、HTML とサーバーサイドの処理だけで、モダンな検索体験を実現する方法を学んでいきましょう。

リアルタイム検索の基本概念

リアルタイム検索とは、ユーザーが検索ボックスに文字を入力するたびに、即座に検索結果が更新される機能のことです。従来の検索では「検索」ボタンを押す必要がありましたが、リアルタイム検索ではその手間が不要になります。

htmx を使ったリアルタイム検索の仕組みは、以下のようになっています:

  1. ユーザーが入力 → 検索ボックスに文字を入力
  2. htmx がイベントを検知hx-triggerで入力イベントを監視
  3. サーバーにリクエスト送信 → 入力内容をサーバーに送信
  4. サーバーで検索処理 → データベースから該当データを検索
  5. 結果を HTML で返却 → 検索結果を HTML 形式で返す
  6. 画面を部分更新 → 結果表示エリアのみを更新

この仕組みにより、JavaScript フレームワークを使わずに、シンプルな HTML とサーバーサイドの処理だけで、モダンな検索体験を実現できます。

htmx を使った検索機能の実装

基本的な検索フォームの作成

まずは、基本的な検索フォームから始めましょう。htmx のhx-gethx-triggerを使って、入力時に自動的にサーバーにリクエストを送信する仕組みを作ります。

html<!-- 検索フォームの基本構造 -->
<div class="search-container">
  <input
    type="text"
    name="query"
    placeholder="検索キーワードを入力..."
    hx-get="/search"
    hx-trigger="keyup changed delay:300ms"
    hx-target="#search-results"
    hx-indicator="#loading"
    class="search-input"
  />
  <div id="loading" class="htmx-indicator">
    <span>検索中...</span>
  </div>
  <div id="search-results" class="search-results">
    <!-- 検索結果がここに表示される -->
  </div>
</div>

このコードのポイントを説明します:

  • hx-get="​/​search": 検索エンドポイントに GET リクエストを送信
  • hx-trigger="keyup changed delay:300ms": キー入力から 300ms 後にリクエスト実行(デバウンス処理)
  • hx-target="#search-results": 検索結果を表示する要素を指定
  • hx-indicator="#loading": ローディング表示用の要素を指定

CSS でスタイリングを追加して、ユーザビリティを向上させましょう:

css/* 検索フォームのスタイリング */
.search-container {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.search-input {
  width: 100%;
  padding: 12px 16px;
  border: 2px solid #e1e5e9;
  border-radius: 8px;
  font-size: 16px;
  transition: border-color 0.3s ease;
}

.search-input:focus {
  outline: none;
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.htmx-indicator {
  display: none;
  text-align: center;
  padding: 10px;
  color: #6b7280;
}

.htmx-request .htmx-indicator {
  display: block;
}

.search-results {
  margin-top: 20px;
  min-height: 100px;
}

サーバーサイドでの検索処理

次に、サーバーサイドで検索処理を実装します。Python Flask を使った例を示します:

python# Flask アプリケーションの基本設定
from flask import Flask, render_template, request, jsonify
from flask_sqlalchemy import SQLAlchemy
import re

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///search_demo.db'
db = SQLAlchemy(app)

# 検索対象のデータモデル
class Product(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False)
    description = db.Column(db.Text)
    category = db.Column(db.String(50))

    def to_dict(self):
        return {
            'id': self.id,
            'name': self.name,
            'description': self.description,
            'category': self.category
        }

検索エンドポイントを実装します:

python# 検索エンドポイントの実装
@app.route('/search')
def search():
    query = request.args.get('query', '').strip()

    if not query:
        return render_template('search_results.html', results=[], query='')

    try:
        # 基本的な検索処理
        results = Product.query.filter(
            Product.name.contains(query) |
            Product.description.contains(query)
        ).limit(10).all()

        return render_template('search_results.html', results=results, query=query)

    except Exception as e:
        # エラーハンドリング
        app.logger.error(f"検索エラー: {str(e)}")
        return render_template('search_error.html', error="検索中にエラーが発生しました")

検索結果を表示するテンプレートを作成します:

html<!-- search_results.html -->
{% if results %}
<div class="results-container">
  <h3>
    「{{ query }}」の検索結果 ({{ results|length }}件)
  </h3>
  <div class="results-list">
    {% for result in results %}
    <div class="result-item">
      <h4>{{ result.name }}</h4>
      <p class="description">
        {{ result.description[:100] }}...
      </p>
      <span class="category">{{ result.category }}</span>
    </div>
    {% endfor %}
  </div>
</div>
{% else %}
<div class="no-results">
  <p>
    「{{ query }}」に一致する結果が見つかりませんでした。
  </p>
  <p>別のキーワードで検索してみてください。</p>
</div>
{% endif %}

リアルタイム更新の仕組み

htmx のリアルタイム更新の仕組みを詳しく見ていきましょう。ユーザーが入力するたびに、どのような流れで画面が更新されるかを理解することが重要です。

html<!-- より高度な検索フォーム -->
<div class="advanced-search">
  <input
    type="text"
    name="query"
    placeholder="商品名や説明を検索..."
    hx-get="/search"
    hx-trigger="keyup changed delay:300ms, search"
    hx-target="#search-results"
    hx-indicator="#loading"
    hx-swap="innerHTML"
    hx-push-url="false"
    class="search-input"
  />

  <!-- 検索オプション -->
  <div class="search-options">
    <select
      name="category"
      hx-get="/search"
      hx-trigger="change"
      hx-target="#search-results"
    >
      <option value="">すべてのカテゴリ</option>
      <option value="electronics">電子機器</option>
      <option value="books">書籍</option>
      <option value="clothing">衣類</option>
    </select>
  </div>
</div>

サーバーサイドでより高度な検索処理を実装します:

python# 高度な検索処理の実装
@app.route('/search')
def advanced_search():
    query = request.args.get('query', '').strip()
    category = request.args.get('category', '')

    try:
        # クエリビルダーの構築
        search_query = Product.query

        if query:
            # 部分一致検索(大文字小文字を区別しない)
            search_query = search_query.filter(
                db.or_(
                    Product.name.ilike(f'%{query}%'),
                    Product.description.ilike(f'%{query}%')
                )
            )

        if category:
            # カテゴリフィルタリング
            search_query = search_query.filter(Product.category == category)

        # 結果の取得とソート
        results = search_query.order_by(Product.name).limit(20).all()

        # 検索統計の計算
        total_count = search_query.count()

        return render_template(
            'search_results.html',
            results=results,
            query=query,
            category=category,
            total_count=total_count
        )

    except Exception as e:
        app.logger.error(f"検索エラー: {str(e)}")
        return render_template(
            'search_error.html',
            error="検索処理中にエラーが発生しました。しばらく時間をおいて再度お試しください。"
        )

オートコンプリート機能の追加

検索候補の表示

オートコンプリート機能を追加することで、ユーザーが入力中に検索候補を表示し、より効率的な検索体験を提供できます。

html<!-- オートコンプリート付き検索フォーム -->
<div class="autocomplete-container">
  <div class="search-wrapper">
    <input
      type="text"
      name="query"
      placeholder="検索キーワードを入力..."
      hx-get="/autocomplete"
      hx-trigger="keyup changed delay:200ms"
      hx-target="#suggestions"
      hx-indicator="#suggestion-loading"
      class="search-input"
      autocomplete="off"
    />
    <div id="suggestion-loading" class="htmx-indicator">
      <span>候補を検索中...</span>
    </div>
  </div>

  <!-- 検索候補の表示エリア -->
  <div id="suggestions" class="suggestions-container"></div>
</div>

オートコンプリート用の CSS を追加します:

css/* オートコンプリートのスタイリング */
.autocomplete-container {
  position: relative;
  max-width: 600px;
  margin: 0 auto;
}

.search-wrapper {
  position: relative;
}

.suggestions-container {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  background: white;
  border: 1px solid #e1e5e9;
  border-top: none;
  border-radius: 0 0 8px 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  z-index: 1000;
  max-height: 300px;
  overflow-y: auto;
}

.suggestion-item {
  padding: 12px 16px;
  cursor: pointer;
  border-bottom: 1px solid #f3f4f6;
  transition: background-color 0.2s ease;
}

.suggestion-item:hover,
.suggestion-item.selected {
  background-color: #f3f4f6;
}

.suggestion-item:last-child {
  border-bottom: none;
}

.suggestion-highlight {
  background-color: #fef3c7;
  font-weight: bold;
}

オートコンプリート用のサーバーサイド処理を実装します:

python# オートコンプリートエンドポイント
@app.route('/autocomplete')
def autocomplete():
    query = request.args.get('query', '').strip()

    if len(query) < 2:
        return render_template('suggestions.html', suggestions=[])

    try:
        # 検索候補の取得
        suggestions = Product.query.filter(
            Product.name.ilike(f'%{query}%')
        ).limit(8).all()

        # カテゴリ別の候補も取得
        category_suggestions = db.session.query(Product.category).filter(
            Product.category.ilike(f'%{query}%')
        ).distinct().limit(3).all()

        return render_template(
            'suggestions.html',
            suggestions=suggestions,
            category_suggestions=category_suggestions,
            query=query
        )

    except Exception as e:
        app.logger.error(f"オートコンプリートエラー: {str(e)}")
        return render_template('suggestions.html', suggestions=[])

検索候補を表示するテンプレートを作成します:

html<!-- suggestions.html -->
{% if suggestions or category_suggestions %}
<div class="suggestions-list">
  {% if suggestions %}
  <div class="suggestion-section">
    <div class="section-title">商品名</div>
    {% for item in suggestions %}
    <div
      class="suggestion-item"
      data-value="{{ item.name }}"
      onclick="selectSuggestion('{{ item.name }}')"
    >
      <span class="suggestion-text">
        {{ item.name|replace(query, '<span
          class="suggestion-highlight"
          >' + query + '</span
        >')|safe }}
      </span>
      <span class="suggestion-category"
        >{{ item.category }}</span
      >
    </div>
    {% endfor %}
  </div>
  {% endif %} {% if category_suggestions %}
  <div class="suggestion-section">
    <div class="section-title">カテゴリ</div>
    {% for category in category_suggestions %}
    <div
      class="suggestion-item"
      data-value="{{ category[0] }}"
      onclick="selectSuggestion('{{ category[0] }}')"
    >
      <span class="suggestion-text">
        {{ category[0]|replace(query, '<span
          class="suggestion-highlight"
          >' + query + '</span
        >')|safe }}
      </span>
      <span class="suggestion-type">カテゴリ</span>
    </div>
    {% endfor %}
  </div>
  {% endif %}
</div>
{% else %}
<div class="no-suggestions">
  <p>検索候補が見つかりませんでした</p>
</div>
{% endif %}

キーボードナビゲーション

オートコンプリートの使いやすさを向上させるために、キーボードナビゲーション機能を追加します。

javascript// キーボードナビゲーションの実装
document.addEventListener('DOMContentLoaded', function () {
  const searchInput =
    document.querySelector('.search-input');
  const suggestionsContainer =
    document.getElementById('suggestions');
  let selectedIndex = -1;
  let suggestions = [];

  // キーボードイベントの処理
  searchInput.addEventListener('keydown', function (e) {
    suggestions = document.querySelectorAll(
      '.suggestion-item'
    );

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        selectedIndex = Math.min(
          selectedIndex + 1,
          suggestions.length - 1
        );
        updateSelection();
        break;

      case 'ArrowUp':
        e.preventDefault();
        selectedIndex = Math.max(selectedIndex - 1, -1);
        updateSelection();
        break;

      case 'Enter':
        e.preventDefault();
        if (
          selectedIndex >= 0 &&
          suggestions[selectedIndex]
        ) {
          selectSuggestion(
            suggestions[selectedIndex].dataset.value
          );
        }
        break;

      case 'Escape':
        hideSuggestions();
        break;
    }
  });

  // 選択状態の更新
  function updateSelection() {
    suggestions.forEach((item, index) => {
      if (index === selectedIndex) {
        item.classList.add('selected');
        item.scrollIntoView({ block: 'nearest' });
      } else {
        item.classList.remove('selected');
      }
    });
  }

  // 候補の選択処理
  window.selectSuggestion = function (value) {
    searchInput.value = value;
    hideSuggestions();

    // 検索実行
    htmx.trigger(searchInput, 'search');
  };

  // 候補の非表示
  function hideSuggestions() {
    suggestionsContainer.innerHTML = '';
    selectedIndex = -1;
  }

  // フォーカスアウト時の処理
  document.addEventListener('click', function (e) {
    if (
      !searchInput.contains(e.target) &&
      !suggestionsContainer.contains(e.target)
    ) {
      hideSuggestions();
    }
  });
});

選択時の処理

検索候補が選択された時の処理を実装します:

html<!-- 選択時の処理を含む検索フォーム -->
<div class="autocomplete-container">
  <div class="search-wrapper">
    <input
      type="text"
      name="query"
      placeholder="検索キーワードを入力..."
      hx-get="/autocomplete"
      hx-trigger="keyup changed delay:200ms"
      hx-target="#suggestions"
      hx-indicator="#suggestion-loading"
      class="search-input"
      autocomplete="off"
      hx-on="htmx:afterRequest: hideSuggestions()"
    />

    <!-- 検索ボタン -->
    <button
      type="button"
      class="search-button"
      hx-get="/search"
      hx-include="[name='query']"
      hx-target="#search-results"
      hx-indicator="#search-loading"
    >
      検索
    </button>
  </div>

  <div id="suggestions" class="suggestions-container"></div>
  <div id="search-loading" class="htmx-indicator">
    <span>検索中...</span>
  </div>
  <div id="search-results" class="search-results"></div>
</div>

選択時の処理を改善した JavaScript を追加します:

javascript// 改善された選択処理
window.selectSuggestion = function (
  value,
  type = 'product'
) {
  const searchInput =
    document.querySelector('.search-input');
  const suggestionsContainer =
    document.getElementById('suggestions');

  // 入力値を更新
  searchInput.value = value;

  // 候補を非表示
  suggestionsContainer.innerHTML = '';

  // 検索タイプに応じた処理
  if (type === 'category') {
    // カテゴリ検索の場合
    htmx.ajax('GET', '/search', {
      target: '#search-results',
      values: { category: value },
    });
  } else {
    // 通常の検索の場合
    htmx.trigger(searchInput, 'search');
  }

  // 入力フィールドにフォーカスを戻す
  searchInput.focus();
};

// 候補の非表示処理
window.hideSuggestions = function () {
  const suggestionsContainer =
    document.getElementById('suggestions');
  suggestionsContainer.innerHTML = '';
};

パフォーマンス最適化

デバウンス処理

ユーザーが入力するたびにリクエストを送信すると、サーバーに負荷がかかり、パフォーマンスが低下します。デバウンス処理を実装して、入力が落ち着いてからリクエストを送信するようにしましょう。

html<!-- デバウンス処理付き検索フォーム -->
<input
  type="text"
  name="query"
  placeholder="検索キーワードを入力..."
  hx-get="/search"
  hx-trigger="keyup changed delay:500ms"
  hx-target="#search-results"
  hx-indicator="#loading"
  class="search-input"
/>

より高度なデバウンス処理を JavaScript で実装します:

javascript// 高度なデバウンス処理
class SearchDebouncer {
  constructor(delay = 300) {
    this.delay = delay;
    this.timeoutId = null;
    this.lastQuery = '';
  }

  debounce(callback, query) {
    // 同じクエリの場合は処理をスキップ
    if (this.lastQuery === query) {
      return;
    }

    // 前のタイマーをクリア
    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
    }

    // 新しいタイマーを設定
    this.timeoutId = setTimeout(() => {
      this.lastQuery = query;
      callback(query);
    }, this.delay);
  }

  cancel() {
    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
      this.timeoutId = null;
    }
  }
}

// デバウンサーの初期化
const searchDebouncer = new SearchDebouncer(400);

// カスタムイベントの作成
document.addEventListener('DOMContentLoaded', function () {
  const searchInput =
    document.querySelector('.search-input');

  searchInput.addEventListener('input', function (e) {
    const query = e.target.value.trim();

    if (query.length < 2) {
      // 短すぎるクエリは処理しない
      return;
    }

    searchDebouncer.debounce((finalQuery) => {
      // htmxリクエストの実行
      htmx.ajax('GET', '/search', {
        target: '#search-results',
        values: { query: finalQuery },
      });
    }, query);
  });
});

キャッシュ戦略

検索結果をキャッシュすることで、同じクエリに対する応答時間を大幅に短縮できます。

python# Redisを使ったキャッシュ実装
import redis
import json
import hashlib
from functools import wraps

# Redis接続の設定
redis_client = redis.Redis(host='localhost', port=6379, db=0)

def cache_search_results(expire_time=300):
    """検索結果をキャッシュするデコレータ"""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # キャッシュキーの生成
            cache_key = f"search:{hashlib.md5(str(kwargs).encode()).hexdigest()}"

            # キャッシュから結果を取得
            cached_result = redis_client.get(cache_key)
            if cached_result:
                return json.loads(cached_result)

            # 実際の検索処理
            result = func(*args, **kwargs)

            # 結果をキャッシュに保存
            redis_client.setex(cache_key, expire_time, json.dumps(result))

            return result
        return wrapper
    return decorator

# キャッシュ付き検索エンドポイント
@app.route('/search')
@cache_search_results(expire_time=600)  # 10分間キャッシュ
def cached_search():
    query = request.args.get('query', '').strip()
    category = request.args.get('category', '')

    # 検索処理(既存のコード)
    # ...

    return {
        'results': [result.to_dict() for result in results],
        'total_count': total_count,
        'query': query
    }

レスポンス時間の改善

レスポンス時間を改善するためのテクニックを実装します:

python# データベースクエリの最適化
@app.route('/search')
def optimized_search():
    query = request.args.get('query', '').strip()

    try:
        # インデックスを活用した効率的な検索
        base_query = Product.query

        if query:
            # 全文検索インデックスを使用
            base_query = base_query.filter(
                db.or_(
                    Product.name.ilike(f'%{query}%'),
                    Product.description.ilike(f'%{query}%')
                )
            )

        # 必要なカラムのみを選択
        results = base_query.with_entities(
            Product.id,
            Product.name,
            Product.description,
            Product.category
        ).limit(20).all()

        # 結果の形式を最適化
        formatted_results = []
        for result in results:
            formatted_results.append({
                'id': result.id,
                'name': result.name,
                'description': result.description[:100] + '...' if len(result.description) > 100 else result.description,
                'category': result.category
            })

        return render_template(
            'search_results.html',
            results=formatted_results,
            query=query
        )

    except Exception as e:
        app.logger.error(f"検索エラー: {str(e)}")
        return render_template('search_error.html', error="検索中にエラーが発生しました")

実践的な応用例

商品検索システム

EC サイトでの商品検索システムを実装してみましょう。価格範囲、カテゴリ、評価などの複数条件での検索が可能なシステムです。

html<!-- 商品検索システムのUI -->
<div class="product-search">
  <div class="search-header">
    <h2>商品検索</h2>
    <p>お探しの商品を素早く見つけましょう</p>
  </div>

  <div class="search-form">
    <!-- 基本検索 -->
    <div class="search-input-group">
      <input
        type="text"
        name="query"
        placeholder="商品名、ブランド、特徴を検索..."
        hx-get="/product-search"
        hx-trigger="keyup changed delay:400ms"
        hx-target="#product-results"
        hx-indicator="#product-loading"
        class="search-input"
      />
    </div>

    <!-- フィルター -->
    <div class="search-filters">
      <select
        name="category"
        hx-get="/product-search"
        hx-trigger="change"
        hx-target="#product-results"
      >
        <option value="">すべてのカテゴリ</option>
        <option value="electronics">電子機器</option>
        <option value="clothing">衣類</option>
        <option value="books">書籍</option>
        <option value="home">ホーム&ガーデン</option>
      </select>

      <select
        name="price_range"
        hx-get="/product-search"
        hx-trigger="change"
        hx-target="#product-results"
      >
        <option value="">価格範囲</option>
        <option value="0-1000">1,000円以下</option>
        <option value="1000-5000">1,000円〜5,000円</option>
        <option value="5000-10000">
          5,000円〜10,000円
        </option>
        <option value="10000-">10,000円以上</option>
      </select>

      <select
        name="sort"
        hx-get="/product-search"
        hx-trigger="change"
        hx-target="#product-results"
      >
        <option value="relevance">関連度順</option>
        <option value="price_asc">価格が安い順</option>
        <option value="price_desc">価格が高い順</option>
        <option value="rating">評価順</option>
      </select>
    </div>
  </div>

  <div id="product-loading" class="htmx-indicator">
    <div class="loading-spinner"></div>
    <span>商品を検索中...</span>
  </div>

  <div id="product-results" class="product-results"></div>
</div>

商品検索用のサーバーサイド処理を実装します:

python# 商品検索エンドポイント
@app.route('/product-search')
def product_search():
    query = request.args.get('query', '').strip()
    category = request.args.get('category', '')
    price_range = request.args.get('price_range', '')
    sort = request.args.get('sort', 'relevance')

    try:
        # 基本クエリの構築
        search_query = Product.query

        # キーワード検索
        if query:
            search_query = search_query.filter(
                db.or_(
                    Product.name.ilike(f'%{query}%'),
                    Product.description.ilike(f'%{query}%'),
                    Product.brand.ilike(f'%{query}%')
                )
            )

        # カテゴリフィルター
        if category:
            search_query = search_query.filter(Product.category == category)

        # 価格範囲フィルター
        if price_range:
            if price_range == '0-1000':
                search_query = search_query.filter(Product.price <= 1000)
            elif price_range == '1000-5000':
                search_query = search_query.filter(
                    Product.price >= 1000,
                    Product.price <= 5000
                )
            elif price_range == '5000-10000':
                search_query = search_query.filter(
                    Product.price >= 5000,
                    Product.price <= 10000
                )
            elif price_range == '10000-':
                search_query = search_query.filter(Product.price >= 10000)

        # ソート処理
        if sort == 'price_asc':
            search_query = search_query.order_by(Product.price.asc())
        elif sort == 'price_desc':
            search_query = search_query.order_by(Product.price.desc())
        elif sort == 'rating':
            search_query = search_query.order_by(Product.rating.desc())
        else:
            # 関連度順(デフォルト)
            search_query = search_query.order_by(Product.name)

        # 結果の取得
        results = search_query.limit(24).all()
        total_count = search_query.count()

        return render_template(
            'product_results.html',
            products=results,
            query=query,
            category=category,
            price_range=price_range,
            sort=sort,
            total_count=total_count
        )

    except Exception as e:
        app.logger.error(f"商品検索エラー: {str(e)}")
        return render_template(
            'search_error.html',
            error="商品検索中にエラーが発生しました"
        )

ユーザー検索機能

SNS や管理画面でのユーザー検索機能を実装します。ユーザー名、メールアドレス、役割などで検索できる機能です。

html<!-- ユーザー検索システム -->
<div class="user-search">
  <div class="search-header">
    <h3>ユーザー検索</h3>
  </div>

  <div class="search-form">
    <input
      type="text"
      name="user_query"
      placeholder="ユーザー名、メールアドレスを検索..."
      hx-get="/user-search"
      hx-trigger="keyup changed delay:300ms"
      hx-target="#user-results"
      hx-indicator="#user-loading"
      class="search-input"
    />

    <div class="user-filters">
      <select
        name="role"
        hx-get="/user-search"
        hx-trigger="change"
        hx-target="#user-results"
      >
        <option value="">すべての役割</option>
        <option value="admin">管理者</option>
        <option value="user">一般ユーザー</option>
        <option value="moderator">モデレーター</option>
      </select>

      <select
        name="status"
        hx-get="/user-search"
        hx-trigger="change"
        hx-target="#user-results"
      >
        <option value="">すべてのステータス</option>
        <option value="active">アクティブ</option>
        <option value="inactive">非アクティブ</option>
        <option value="suspended">停止中</option>
      </select>
    </div>
  </div>

  <div id="user-loading" class="htmx-indicator">
    <span>ユーザーを検索中...</span>
  </div>

  <div id="user-results" class="user-results"></div>
</div>

ユーザー検索用のサーバーサイド処理:

python# ユーザー検索エンドポイント
@app.route('/user-search')
def user_search():
    query = request.args.get('user_query', '').strip()
    role = request.args.get('role', '')
    status = request.args.get('status', '')

    try:
        # ユーザー検索クエリの構築
        search_query = User.query

        # キーワード検索
        if query:
            search_query = search_query.filter(
                db.or_(
                    User.username.ilike(f'%{query}%'),
                    User.email.ilike(f'%{query}%'),
                    User.display_name.ilike(f'%{query}%')
                )
            )

        # 役割フィルター
        if role:
            search_query = search_query.filter(User.role == role)

        # ステータスフィルター
        if status:
            search_query = search_query.filter(User.status == status)

        # 結果の取得
        results = search_query.order_by(User.username).limit(20).all()
        total_count = search_query.count()

        return render_template(
            'user_results.html',
            users=results,
            query=query,
            role=role,
            status=status,
            total_count=total_count
        )

    except Exception as e:
        app.logger.error(f"ユーザー検索エラー: {str(e)}")
        return render_template(
            'search_error.html',
            error="ユーザー検索中にエラーが発生しました"
        )

タグ検索の実装

ブログや記事管理システムでのタグ検索機能を実装します。複数のタグを組み合わせて検索できる機能です。

html<!-- タグ検索システム -->
<div class="tag-search">
  <div class="search-header">
    <h3>タグ検索</h3>
    <p>関連するタグを選択して記事を絞り込み</p>
  </div>

  <div class="tag-input-container">
    <div class="selected-tags" id="selected-tags"></div>
    <input
      type="text"
      name="tag_query"
      placeholder="タグを検索して追加..."
      hx-get="/tag-suggestions"
      hx-trigger="keyup changed delay:200ms"
      hx-target="#tag-suggestions"
      hx-indicator="#tag-loading"
      class="tag-input"
    />
    <div id="tag-suggestions" class="tag-suggestions"></div>
  </div>

  <div class="tag-filters">
    <button
      type="button"
      class="filter-btn active"
      data-filter="all"
      hx-get="/article-search"
      hx-trigger="click"
      hx-target="#article-results"
    >
      すべて
    </button>
    <button
      type="button"
      class="filter-btn"
      data-filter="recent"
      hx-get="/article-search"
      hx-trigger="click"
      hx-target="#article-results"
    >
      最近の記事
    </button>
    <button
      type="button"
      class="filter-btn"
      data-filter="popular"
      hx-get="/article-search"
      hx-trigger="click"
      hx-target="#article-results"
    >
      人気記事
    </button>
  </div>

  <div id="tag-loading" class="htmx-indicator">
    <span>タグを検索中...</span>
  </div>

  <div id="article-results" class="article-results"></div>
</div>

タグ検索用の JavaScript 処理:

javascript// タグ検索の管理
class TagSearchManager {
  constructor() {
    this.selectedTags = new Set();
    this.init();
  }

  init() {
    this.bindEvents();
    this.updateSelectedTagsDisplay();
  }

  bindEvents() {
    // タグ選択イベント
    document.addEventListener('click', (e) => {
      if (
        e.target.classList.contains('tag-suggestion-item')
      ) {
        this.addTag(e.target.dataset.tag);
      }

      if (e.target.classList.contains('remove-tag')) {
        this.removeTag(e.target.dataset.tag);
      }
    });

    // フィルターボタンのイベント
    document.addEventListener('click', (e) => {
      if (e.target.classList.contains('filter-btn')) {
        this.updateActiveFilter(e.target);
      }
    });
  }

  addTag(tag) {
    this.selectedTags.add(tag);
    this.updateSelectedTagsDisplay();
    this.performSearch();
  }

  removeTag(tag) {
    this.selectedTags.delete(tag);
    this.updateSelectedTagsDisplay();
    this.performSearch();
  }

  updateSelectedTagsDisplay() {
    const container =
      document.getElementById('selected-tags');
    container.innerHTML = '';

    this.selectedTags.forEach((tag) => {
      const tagElement = document.createElement('span');
      tagElement.className = 'selected-tag';
      tagElement.innerHTML = `
                ${tag}
                <button class="remove-tag" data-tag="${tag}">&times;</button>
            `;
      container.appendChild(tagElement);
    });
  }

  updateActiveFilter(button) {
    // アクティブクラスの更新
    document
      .querySelectorAll('.filter-btn')
      .forEach((btn) => {
        btn.classList.remove('active');
      });
    button.classList.add('active');
  }

  performSearch() {
    const tags = Array.from(this.selectedTags);
    const activeFilter = document.querySelector(
      '.filter-btn.active'
    ).dataset.filter;

    // htmxリクエストの実行
    htmx.ajax('GET', '/article-search', {
      target: '#article-results',
      values: {
        tags: tags.join(','),
        filter: activeFilter,
      },
    });
  }
}

// 初期化
document.addEventListener('DOMContentLoaded', function () {
  new TagSearchManager();
});

まとめ

htmx を使ったリアルタイム検索とオートコンプリート機能の実装について、段階的に解説してきました。従来の JavaScript フレームワークを使わずに、HTML とサーバーサイドの処理だけで、モダンな検索体験を実現できることがお分かりいただけたと思います。

htmx の最大の魅力は、そのシンプルさとパワーにあります。複雑な JavaScript コードを書く必要がなく、HTML の属性だけで動的な機能を実現できます。これにより、開発者はサーバーサイドのロジックに集中でき、保守性の高いコードを書くことができます。

実装のポイントをまとめると:

  1. 適切なデバウンス処理でサーバー負荷を軽減
  2. キャッシュ戦略でレスポンス時間を改善
  3. キーボードナビゲーションでユーザビリティを向上
  4. エラーハンドリングで堅牢性を確保
  5. パフォーマンス最適化で快適な体験を提供

これらの要素を組み合わせることで、ユーザーが直感的に使える検索機能を実現できます。htmx の特性を活かした実装により、軽量で高速、そして保守しやすい検索システムを構築できるでしょう。

今後も htmx の新しい機能やベストプラクティスが登場する可能性があります。常に最新の情報をキャッチアップし、より良いユーザー体験を提供するための技術を学び続けることが重要です。

関連リンク