T-CREATOR

htmx の history 拡張で快適な SPA ライク体験を実現

htmx の history 拡張で快適な SPA ライク体験を実現

htmx を使った Web アプリケーション開発で、最も感動的な瞬間の一つが「history 拡張」を実装した時です。従来のマルチページアプリケーションの制約から解放され、まるで SPA のような滑らかなユーザー体験を実現できるからです。

この記事では、htmx の history 拡張機能を徹底的に解説し、実際のプロジェクトで即座に活用できる実践的なテクニックをお伝えします。初心者の方でも理解しやすいように、段階的に進めていきましょう。

htmx の history 拡張とは

htmx の history 拡張は、ブラウザの履歴管理を自動化し、SPA のようなページ遷移体験を提供する機能です。従来の htmx では、コンテンツの更新時にブラウザの URL が変わらず、ユーザーが「戻る」ボタンを押すと予期しない動作をすることがありました。

history 拡張を導入することで、以下のような体験が実現できます:

  • ページ遷移時に URL が自動的に更新される
  • ブラウザの戻る・進むボタンが正常に動作する
  • ページのタイトルが動的に変更される
  • ブックマークや共有リンクが正常に機能する

従来の htmx と history 拡張の違い

従来の htmx では、以下のような制限がありました:

html<!-- 従来のhtmx(history拡張なし) -->
<div hx-get="/products" hx-target="#content">
  商品一覧を見る
</div>

この場合、コンテンツは更新されますが、URL は変わらず、ブラウザの履歴にも記録されません。

history 拡張を使用すると:

html<!-- history拡張を使用 -->
<div
  hx-get="/products"
  hx-target="#content"
  hx-push-url="true"
  hx-history="true"
>
  商品一覧を見る
</div>

これにより、URL が​/​productsに更新され、ブラウザの履歴にも正しく記録されます。

基本的なセットアップと設定

htmx の history 拡張を始めるには、まず適切なセットアップが必要です。最新の htmx では、history 拡張が標準で含まれているため、追加のライブラリは不要です。

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 History 拡張デモ</title>

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

    <!-- history拡張の読み込み -->
    <script src="https://unpkg.com/htmx.org@1.9.10/ext/history.js"></script>
  </head>
  <body>
    <!-- ナビゲーション -->
    <nav>
      <a
        href="/"
        hx-get="/"
        hx-target="#main"
        hx-push-url="true"
        >ホーム</a
      >
      <a
        href="/about"
        hx-get="/about"
        hx-target="#main"
        hx-push-url="true"
        >会社概要</a
      >
      <a
        href="/products"
        hx-get="/products"
        hx-target="#main"
        hx-push-url="true"
        >商品一覧</a
      >
    </nav>

    <!-- メインコンテンツエリア -->
    <main id="main">
      <h1>ようこそ</h1>
      <p>htmxのhistory拡張を体験してください。</p>
    </main>
  </body>
</html>

サーバーサイドの準備

Node.js と Express を使用した基本的なサーバー設定例:

javascriptconst express = require('express');
const app = express();
const port = 3000;

// 静的ファイルの提供
app.use(express.static('public'));

// 各ページのルート
app.get('/', (req, res) => {
  res.send(`
        <h1>ホームページ</h1>
        <p>これはホームページのコンテンツです。</p>
        <a href="/about" hx-get="/about" hx-target="#main" hx-push-url="true">
            会社概要へ
        </a>
    `);
});

app.get('/about', (req, res) => {
  res.send(`
        <h1>会社概要</h1>
        <p>私たちの会社について紹介します。</p>
        <a href="/products" hx-get="/products" hx-target="#main" hx-push-url="true">
            商品一覧へ
        </a>
    `);
});

app.get('/products', (req, res) => {
  res.send(`
        <h1>商品一覧</h1>
        <ul>
            <li>商品A</li>
            <li>商品B</li>
            <li>商品C</li>
        </ul>
        <a href="/" hx-get="/" hx-target="#main" hx-push-url="true">
            ホームへ戻る
        </a>
    `);
});

app.listen(port, () => {
  console.log(
    `サーバーが起動しました: http://localhost:${port}`
  );
});

ページ遷移の実装方法

history 拡張を使ったページ遷移の実装には、いくつかの重要な属性があります。それぞれの役割と使い方を詳しく見ていきましょう。

基本的な属性の説明

html<!-- 基本的なhistory拡張の実装 -->
<div
  hx-get="/products"
  hx-target="#main"
  hx-push-url="true"
  hx-history="true"
>
  商品一覧を表示
</div>

各属性の役割:

  • hx-get: リクエストする URL
  • hx-target: 更新対象の要素
  • hx-push-url: URL を履歴に追加するかどうか
  • hx-history: history 拡張を有効にするかどうか

動的な URL 生成

JavaScript を使って動的に URL を生成する方法:

html<!-- 動的URL生成の例 -->
<button onclick="loadProduct(123)">商品詳細を見る</button>

<script>
  function loadProduct(productId) {
    htmx.ajax('GET', `/products/${productId}`, {
      target: '#main',
      pushUrl: true,
    });
  }
</script>

フォーム送信での history 管理

フォーム送信時にも history 拡張を活用できます:

html<!-- 検索フォームの例 -->
<form
  hx-post="/search"
  hx-target="#results"
  hx-push-url="true"
  hx-history="true"
>
  <input
    type="text"
    name="query"
    placeholder="検索キーワード"
  />
  <button type="submit">検索</button>
</form>

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

ブラウザの戻る・進むボタンへの対応

ブラウザの戻る・進むボタンが正常に動作するようにするには、適切なイベントハンドリングが必要です。

基本的な戻る・進むボタンの対応

javascript// history拡張のイベントリスナー設定
document.addEventListener(
  'htmx:historyRestore',
  function (evt) {
    console.log('履歴が復元されました:', evt.detail);

    // 必要に応じて追加の処理を実行
    updatePageTitle(evt.detail.path);
    highlightCurrentNav(evt.detail.path);
  }
);

// ページタイトルの更新
function updatePageTitle(path) {
  const titles = {
    '/': 'ホーム',
    '/about': '会社概要',
    '/products': '商品一覧',
  };

  document.title = titles[path] || 'デフォルトタイトル';
}

// 現在のナビゲーションをハイライト
function highlightCurrentNav(path) {
  // すべてのナビリンクからactiveクラスを削除
  document.querySelectorAll('nav a').forEach((link) => {
    link.classList.remove('active');
  });

  // 現在のパスに一致するリンクにactiveクラスを追加
  const currentLink = document.querySelector(
    `nav a[href="${path}"]`
  );
  if (currentLink) {
    currentLink.classList.add('active');
  }
}

カスタム履歴管理

より細かい制御が必要な場合のカスタム実装:

javascript// カスタム履歴管理の例
class CustomHistoryManager {
  constructor() {
    this.currentPath = window.location.pathname;
    this.setupEventListeners();
  }

  setupEventListeners() {
    // htmxの履歴復元イベント
    document.addEventListener(
      'htmx:historyRestore',
      (evt) => {
        this.handleHistoryRestore(evt.detail);
      }
    );

    // htmxの履歴追加イベント
    document.addEventListener('htmx:historyPush', (evt) => {
      this.handleHistoryPush(evt.detail);
    });
  }

  handleHistoryRestore(detail) {
    console.log('履歴復元:', detail);
    this.currentPath = detail.path;
    this.updateUI();
  }

  handleHistoryPush(detail) {
    console.log('履歴追加:', detail);
    this.currentPath = detail.path;
    this.updateUI();
  }

  updateUI() {
    // UIの更新処理
    this.updateBreadcrumb();
    this.updatePageTitle();
    this.updateNavigation();
  }

  updateBreadcrumb() {
    const breadcrumb =
      document.getElementById('breadcrumb');
    if (breadcrumb) {
      breadcrumb.innerHTML = this.generateBreadcrumbHTML();
    }
  }

  generateBreadcrumbHTML() {
    const paths = this.currentPath
      .split('/')
      .filter((p) => p);
    let html =
      '<a href="/" hx-get="/" hx-target="#main" hx-push-url="true">ホーム</a>';

    let currentPath = '';
    paths.forEach((path) => {
      currentPath += `/${path}`;
      html += ` > <a href="${currentPath}" hx-get="${currentPath}" hx-target="#main" hx-push-url="true">${path}</a>`;
    });

    return html;
  }
}

// 初期化
const historyManager = new CustomHistoryManager();

動的なタイトル変更

ページ遷移時に動的にタイトルを変更することで、ユーザーエクスペリエンスを向上させることができます。

基本的なタイトル変更

javascript// 基本的なタイトル変更の実装
document.addEventListener(
  'htmx:historyRestore',
  function (evt) {
    const path = evt.detail.path;
    updateTitle(path);
  }
);

function updateTitle(path) {
  const titleMap = {
    '/': 'ホーム - マイサイト',
    '/about': '会社概要 - マイサイト',
    '/products': '商品一覧 - マイサイト',
    '/contact': 'お問い合わせ - マイサイト',
  };

  const newTitle = titleMap[path] || 'マイサイト';
  document.title = newTitle;
}

動的コンテンツに基づくタイトル変更

サーバーから返されるデータに基づいてタイトルを動的に変更する方法:

html<!-- 商品詳細ページの例 -->
<div
  hx-get="/products/123"
  hx-target="#main"
  hx-push-url="true"
  hx-swap="innerHTML"
>
  商品詳細を見る
</div>

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

javascriptapp.get('/products/:id', (req, res) => {
  const productId = req.params.id;

  // 商品データを取得(実際の実装ではデータベースから取得)
  const product = getProductById(productId);

  // タイトルを含むHTMLを返す
  res.send(`
        <h1>${product.name}</h1>
        <p>${product.description}</p>
        <script>
            // タイトルを動的に更新
            document.title = '${product.name} - 商品詳細 - マイサイト';
        </script>
    `);
});

メタタグの動的更新

SEO 対策としてメタタグも動的に更新する方法:

javascript// メタタグの動的更新
function updateMetaTags(path, data) {
  // タイトルの更新
  document.title = data.title || 'デフォルトタイトル';

  // メタディスクリプションの更新
  const metaDescription = document.querySelector(
    'meta[name="description"]'
  );
  if (metaDescription && data.description) {
    metaDescription.setAttribute(
      'content',
      data.description
    );
  }

  // OGPタグの更新
  const ogTitle = document.querySelector(
    'meta[property="og:title"]'
  );
  if (ogTitle && data.title) {
    ogTitle.setAttribute('content', data.title);
  }

  const ogDescription = document.querySelector(
    'meta[property="og:description"]'
  );
  if (ogDescription && data.description) {
    ogDescription.setAttribute('content', data.description);
  }
}

ローディング状態の管理

ページ遷移時のローディング状態を適切に管理することで、ユーザーに処理状況を伝えることができます。

基本的なローディング表示

html<!-- ローディングインジケーター -->
<div
  id="loading"
  class="loading-spinner"
  style="display: none;"
>
  <div class="spinner"></div>
  <p>読み込み中...</p>
</div>

<style>
  .loading-spinner {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background: rgba(255, 255, 255, 0.9);
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    z-index: 1000;
  }

  .spinner {
    width: 40px;
    height: 40px;
    border: 4px solid #f3f3f3;
    border-top: 4px solid #3498db;
    border-radius: 50%;
    animation: spin 1s linear infinite;
    margin: 0 auto 10px;
  }

  @keyframes spin {
    0% {
      transform: rotate(0deg);
    }
    100% {
      transform: rotate(360deg);
    }
  }
</style>

htmx イベントを使ったローディング管理

javascript// htmxのイベントを使ったローディング管理
document.addEventListener(
  'htmx:beforeRequest',
  function (evt) {
    showLoading();
  }
);

document.addEventListener(
  'htmx:afterRequest',
  function (evt) {
    hideLoading();
  }
);

document.addEventListener(
  'htmx:responseError',
  function (evt) {
    hideLoading();
    showError('リクエストに失敗しました');
  }
);

function showLoading() {
  const loading = document.getElementById('loading');
  if (loading) {
    loading.style.display = 'block';
  }
}

function hideLoading() {
  const loading = document.getElementById('loading');
  if (loading) {
    loading.style.display = 'none';
  }
}

function showError(message) {
  // エラーメッセージの表示
  const errorDiv = document.createElement('div');
  errorDiv.className = 'error-message';
  errorDiv.textContent = message;
  document.body.appendChild(errorDiv);

  // 3秒後に自動削除
  setTimeout(() => {
    errorDiv.remove();
  }, 3000);
}

スケルトンローディング

より洗練されたローディング体験を提供するスケルトンローディング:

html<!-- スケルトンローディングの例 -->
<div
  id="skeleton"
  class="skeleton-loading"
  style="display: none;"
>
  <div class="skeleton-header"></div>
  <div class="skeleton-content">
    <div class="skeleton-line"></div>
    <div class="skeleton-line"></div>
    <div class="skeleton-line"></div>
  </div>
</div>

<style>
  .skeleton-loading {
    padding: 20px;
  }

  .skeleton-header {
    height: 32px;
    background: linear-gradient(
      90deg,
      #f0f0f0 25%,
      #e0e0e0 50%,
      #f0f0f0 75%
    );
    background-size: 200% 100%;
    animation: loading 1.5s infinite;
    border-radius: 4px;
    margin-bottom: 20px;
  }

  .skeleton-line {
    height: 16px;
    background: linear-gradient(
      90deg,
      #f0f0f0 25%,
      #e0e0e0 50%,
      #f0f0f0 75%
    );
    background-size: 200% 100%;
    animation: loading 1.5s infinite;
    border-radius: 4px;
    margin-bottom: 12px;
  }

  @keyframes loading {
    0% {
      background-position: 200% 0;
    }
    100% {
      background-position: -200% 0;
    }
  }
</style>

エラーハンドリング

htmx の history 拡張を使用する際のエラーハンドリングは、ユーザー体験を左右する重要な要素です。

基本的なエラーハンドリング

javascript// 基本的なエラーハンドリング
document.addEventListener(
  'htmx:responseError',
  function (evt) {
    console.error('HTMX エラー:', evt.detail);

    const status = evt.detail.xhr.status;
    const path = evt.detail.pathInfo.requestPath;

    handleError(status, path);
  }
);

function handleError(status, path) {
  const errorMessages = {
    404: 'ページが見つかりませんでした',
    500: 'サーバーエラーが発生しました',
    403: 'アクセスが拒否されました',
    401: '認証が必要です',
  };

  const message =
    errorMessages[status] ||
    '予期しないエラーが発生しました';

  // エラーページを表示
  showErrorPage(message, status);
}

function showErrorPage(message, status) {
  const main = document.getElementById('main');
  if (main) {
    main.innerHTML = `
            <div class="error-page">
                <h1>エラー ${status}</h1>
                <p>${message}</p>
                <button onclick="goHome()">ホームに戻る</button>
            </div>
        `;
  }
}

function goHome() {
  htmx.ajax('GET', '/', {
    target: '#main',
    pushUrl: true,
  });
}

ネットワークエラーの処理

javascript// ネットワークエラーの処理
document.addEventListener('htmx:sendError', function (evt) {
  console.error('ネットワークエラー:', evt.detail);

  showNetworkError();
});

function showNetworkError() {
  const errorDiv = document.createElement('div');
  errorDiv.className = 'network-error';
  errorDiv.innerHTML = `
        <div class="error-content">
            <h3>ネットワークエラー</h3>
            <p>インターネット接続を確認してください</p>
            <button onclick="retryLastRequest()">再試行</button>
        </div>
    `;

  document.body.appendChild(errorDiv);
}

function retryLastRequest() {
  // 最後のリクエストを再試行
  const lastRequest = htmx.lastRequest;
  if (lastRequest) {
    htmx.ajax(lastRequest.method, lastRequest.url, {
      target: lastRequest.target,
      pushUrl: true,
    });
  }

  // エラーメッセージを削除
  const errorDiv = document.querySelector('.network-error');
  if (errorDiv) {
    errorDiv.remove();
  }
}

タイムアウト処理

javascript// タイムアウト処理の設定
document.addEventListener(
  'htmx:beforeRequest',
  function (evt) {
    // タイムアウトを設定(5秒)
    evt.detail.xhr.timeout = 5000;
  }
);

document.addEventListener('htmx:timeout', function (evt) {
  console.error('リクエストがタイムアウトしました');

  showTimeoutError();
});

function showTimeoutError() {
  const main = document.getElementById('main');
  if (main) {
    main.innerHTML = `
            <div class="timeout-error">
                <h2>タイムアウトエラー</h2>
                <p>リクエストが時間内に完了しませんでした</p>
                <button onclick="retryRequest()">再試行</button>
            </div>
        `;
  }
}

パフォーマンス最適化のコツ

htmx の history 拡張を使用する際のパフォーマンス最適化について、実践的なテクニックを紹介します。

キャッシュ戦略

javascript// キャッシュ戦略の実装
class HTMXCache {
  constructor() {
    this.cache = new Map();
    this.maxSize = 50; // 最大キャッシュ数
  }

  set(key, data) {
    // キャッシュサイズを制限
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }

    this.cache.set(key, {
      data: data,
      timestamp: Date.now(),
    });
  }

  get(key) {
    const item = this.cache.get(key);
    if (!item) return null;

    // 5分間のキャッシュ有効期限
    const fiveMinutes = 5 * 60 * 1000;
    if (Date.now() - item.timestamp > fiveMinutes) {
      this.cache.delete(key);
      return null;
    }

    return item.data;
  }

  clear() {
    this.cache.clear();
  }
}

const htmxCache = new HTMXCache();

// キャッシュを使ったリクエスト
document.addEventListener(
  'htmx:beforeRequest',
  function (evt) {
    const url = evt.detail.pathInfo.requestPath;
    const cachedData = htmxCache.get(url);

    if (cachedData) {
      // キャッシュからデータを取得
      evt.preventDefault();
      evt.detail.target.innerHTML = cachedData;
      return;
    }
  }
);

document.addEventListener(
  'htmx:afterRequest',
  function (evt) {
    const url = evt.detail.pathInfo.requestPath;
    const response = evt.detail.target.innerHTML;

    // レスポンスをキャッシュに保存
    htmxCache.set(url, response);
  }
);

遅延読み込み

html<!-- 遅延読み込みの実装 -->
<div
  class="lazy-content"
  hx-get="/api/products"
  hx-trigger="intersect once"
  hx-target="this"
  hx-swap="innerHTML"
>
  <div class="loading-placeholder">
    <div class="spinner"></div>
    <p>コンテンツを読み込み中...</p>
  </div>
</div>

<style>
  .lazy-content {
    min-height: 200px;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .loading-placeholder {
    text-align: center;
    color: #666;
  }
</style>

プリロード戦略

javascript// プリロード戦略の実装
class PreloadManager {
  constructor() {
    this.preloaded = new Set();
    this.setupPreloadTriggers();
  }

  setupPreloadTriggers() {
    // ホバー時にプリロード
    document.addEventListener('mouseover', (evt) => {
      const link = evt.target.closest('[hx-get]');
      if (
        link &&
        !this.preloaded.has(link.getAttribute('hx-get'))
      ) {
        this.preload(link.getAttribute('hx-get'));
      }
    });
  }

  preload(url) {
    if (this.preloaded.has(url)) return;

    // プリロードリクエスト
    fetch(url, {
      method: 'GET',
      headers: {
        'X-Requested-With': 'XMLHttpRequest',
      },
    })
      .then((response) => {
        if (response.ok) {
          this.preloaded.add(url);
          console.log(`プリロード完了: ${url}`);
        }
      })
      .catch((error) => {
        console.warn(`プリロード失敗: ${url}`, error);
      });
  }
}

const preloadManager = new PreloadManager();

メモリリークの防止

javascript// メモリリーク防止の実装
class MemoryManager {
  constructor() {
    this.eventListeners = new Map();
    this.setupCleanup();
  }

  setupCleanup() {
    // ページ離脱時のクリーンアップ
    window.addEventListener('beforeunload', () => {
      this.cleanup();
    });

    // 定期的なクリーンアップ
    setInterval(() => {
      this.cleanup();
    }, 300000); // 5分ごと
  }

  cleanup() {
    // イベントリスナーのクリーンアップ
    this.eventListeners.forEach((listener, element) => {
      if (!document.contains(element)) {
        element.removeEventListener('click', listener);
        this.eventListeners.delete(element);
      }
    });

    // 不要なDOM要素の削除
    const orphanedElements = document.querySelectorAll(
      '[data-htmx-temp]'
    );
    orphanedElements.forEach((element) => {
      element.remove();
    });
  }

  addEventListener(element, event, listener) {
    element.addEventListener(event, listener);
    this.eventListeners.set(element, listener);
  }
}

const memoryManager = new MemoryManager();

実践的な使用例

実際のプロジェクトで htmx の history 拡張を活用する実践的な例を紹介します。

ブログサイトの実装例

html<!-- ブログサイトのメインレイアウト -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>htmx ブログ</title>
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
    <script src="https://unpkg.com/htmx.org@1.9.10/ext/history.js"></script>
    <style>
      .blog-layout {
        max-width: 1200px;
        margin: 0 auto;
        padding: 20px;
      }

      .blog-header {
        background: #f8f9fa;
        padding: 20px;
        margin-bottom: 30px;
        border-radius: 8px;
      }

      .blog-nav {
        display: flex;
        gap: 20px;
        margin-bottom: 20px;
      }

      .blog-nav a {
        text-decoration: none;
        color: #333;
        padding: 10px 15px;
        border-radius: 4px;
        transition: background-color 0.3s;
      }

      .blog-nav a:hover {
        background-color: #e9ecef;
      }

      .blog-nav a.active {
        background-color: #007bff;
        color: white;
      }

      .blog-content {
        min-height: 400px;
      }

      .article-card {
        border: 1px solid #ddd;
        border-radius: 8px;
        padding: 20px;
        margin-bottom: 20px;
        transition: box-shadow 0.3s;
      }

      .article-card:hover {
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
      }
    </style>
  </head>
  <body>
    <div class="blog-layout">
      <header class="blog-header">
        <h1>htmx ブログ</h1>
        <nav class="blog-nav">
          <a
            href="/"
            hx-get="/"
            hx-target="#content"
            hx-push-url="true"
            class="active"
            >ホーム</a
          >
          <a
            href="/articles"
            hx-get="/articles"
            hx-target="#content"
            hx-push-url="true"
            >記事一覧</a
          >
          <a
            href="/categories"
            hx-get="/categories"
            hx-target="#content"
            hx-push-url="true"
            >カテゴリー</a
          >
          <a
            href="/about"
            hx-get="/about"
            hx-target="#content"
            hx-push-url="true"
            >このブログについて</a
          >
        </nav>
      </header>

      <main id="content" class="blog-content">
        <h2>ようこそ</h2>
        <p>htmxのhistory拡張を使ったブログサイトです。</p>
      </main>
    </div>

    <script>
      // ナビゲーションのアクティブ状態管理
      document.addEventListener(
        'htmx:historyRestore',
        function (evt) {
          updateNavigation(evt.detail.path);
        }
      );

      function updateNavigation(path) {
        // すべてのナビリンクからactiveクラスを削除
        document
          .querySelectorAll('.blog-nav a')
          .forEach((link) => {
            link.classList.remove('active');
          });

        // 現在のパスに一致するリンクにactiveクラスを追加
        const currentLink = document.querySelector(
          `.blog-nav a[href="${path}"]`
        );
        if (currentLink) {
          currentLink.classList.add('active');
        }
      }
    </script>
  </body>
</html>

E コマースサイトの実装例

html<!-- 商品一覧ページ -->
<div class="products-page">
  <div class="filters">
    <select
      hx-get="/products"
      hx-target="#products-grid"
      hx-push-url="true"
      hx-trigger="change"
      name="category"
    >
      <option value="">すべてのカテゴリー</option>
      <option value="electronics">電子機器</option>
      <option value="clothing">衣類</option>
      <option value="books">書籍</option>
    </select>

    <select
      hx-get="/products"
      hx-target="#products-grid"
      hx-push-url="true"
      hx-trigger="change"
      name="sort"
    >
      <option value="name">名前順</option>
      <option value="price">価格順</option>
      <option value="newest">新着順</option>
    </select>
  </div>

  <div id="products-grid" class="products-grid">
    <!-- 商品がここに表示される -->
  </div>

  <div class="pagination">
    <button
      hx-get="/products?page=1"
      hx-target="#products-grid"
      hx-push-url="true"
      disabled
    >
      前へ
    </button>
    <span>ページ 1 / 10</span>
    <button
      hx-get="/products?page=2"
      hx-target="#products-grid"
      hx-push-url="true"
    >
      次へ
    </button>
  </div>
</div>

<style>
  .products-page {
    padding: 20px;
  }

  .filters {
    display: flex;
    gap: 15px;
    margin-bottom: 30px;
  }

  .filters select {
    padding: 8px 12px;
    border: 1px solid #ddd;
    border-radius: 4px;
  }

  .products-grid {
    display: grid;
    grid-template-columns: repeat(
      auto-fill,
      minmax(250px, 1fr)
    );
    gap: 20px;
    margin-bottom: 30px;
  }

  .product-card {
    border: 1px solid #ddd;
    border-radius: 8px;
    padding: 15px;
    text-align: center;
    transition: transform 0.2s;
  }

  .product-card:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  }

  .pagination {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 15px;
  }

  .pagination button {
    padding: 8px 16px;
    border: 1px solid #ddd;
    border-radius: 4px;
    background: white;
    cursor: pointer;
  }

  .pagination button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
</style>

管理画面の実装例

html<!-- 管理画面のレイアウト -->
<div class="admin-layout">
  <aside class="sidebar">
    <nav class="admin-nav">
      <a
        href="/admin/dashboard"
        hx-get="/admin/dashboard"
        hx-target="#admin-content"
        hx-push-url="true"
        class="nav-item active"
      >
        <span class="icon">📊</span>
        ダッシュボード
      </a>
      <a
        href="/admin/users"
        hx-get="/admin/users"
        hx-target="#admin-content"
        hx-push-url="true"
        class="nav-item"
      >
        <span class="icon">👥</span>
        ユーザー管理
      </a>
      <a
        href="/admin/products"
        hx-get="/admin/products"
        hx-target="#admin-content"
        hx-push-url="true"
        class="nav-item"
      >
        <span class="icon">📦</span>
        商品管理
      </a>
      <a
        href="/admin/orders"
        hx-get="/admin/orders"
        hx-target="#admin-content"
        hx-push-url="true"
        class="nav-item"
      >
        <span class="icon">🛒</span>
        注文管理
      </a>
    </nav>
  </aside>

  <main class="admin-main">
    <header class="admin-header">
      <h1 id="page-title">ダッシュボード</h1>
      <div class="admin-actions">
        <button class="btn-primary">新規作成</button>
        <button class="btn-secondary">設定</button>
      </div>
    </header>

    <div id="admin-content" class="admin-content">
      <!-- 管理画面のコンテンツがここに表示される -->
    </div>
  </main>
</div>

<style>
  .admin-layout {
    display: flex;
    min-height: 100vh;
  }

  .sidebar {
    width: 250px;
    background: #2c3e50;
    color: white;
    padding: 20px 0;
  }

  .admin-nav {
    display: flex;
    flex-direction: column;
  }

  .nav-item {
    display: flex;
    align-items: center;
    padding: 15px 20px;
    color: white;
    text-decoration: none;
    transition: background-color 0.3s;
  }

  .nav-item:hover {
    background-color: #34495e;
  }

  .nav-item.active {
    background-color: #3498db;
  }

  .nav-item .icon {
    margin-right: 10px;
    font-size: 18px;
  }

  .admin-main {
    flex: 1;
    background: #f8f9fa;
  }

  .admin-header {
    background: white;
    padding: 20px;
    border-bottom: 1px solid #ddd;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .admin-content {
    padding: 20px;
  }

  .btn-primary {
    background: #3498db;
    color: white;
    border: none;
    padding: 10px 20px;
    border-radius: 4px;
    cursor: pointer;
    margin-right: 10px;
  }

  .btn-secondary {
    background: #95a5a6;
    color: white;
    border: none;
    padding: 10px 20px;
    border-radius: 4px;
    cursor: pointer;
  }
</style>

<script>
  // 管理画面のナビゲーション管理
  document.addEventListener(
    'htmx:historyRestore',
    function (evt) {
      updateAdminNavigation(evt.detail.path);
      updatePageTitle(evt.detail.path);
    }
  );

  function updateAdminNavigation(path) {
    // ナビゲーションのアクティブ状態を更新
    document
      .querySelectorAll('.nav-item')
      .forEach((item) => {
        item.classList.remove('active');
      });

    const currentNav = document.querySelector(
      `.nav-item[href="${path}"]`
    );
    if (currentNav) {
      currentNav.classList.add('active');
    }
  }

  function updatePageTitle(path) {
    const titles = {
      '/admin/dashboard': 'ダッシュボード',
      '/admin/users': 'ユーザー管理',
      '/admin/products': '商品管理',
      '/admin/orders': '注文管理',
    };

    const title = titles[path] || '管理画面';
    document.getElementById('page-title').textContent =
      title;
    document.title = `${title} - 管理画面`;
  }
</script>

まとめ

htmx の history 拡張機能は、従来のマルチページアプリケーションに SPA のような滑らかなユーザー体験をもたらす革新的な技術です。この記事で紹介した実践的なテクニックを活用することで、ユーザーが感動する Web アプリケーションを構築できるでしょう。

重要なポイントの振り返り

  1. 適切なセットアップ: history 拡張の正しい読み込みと設定が成功の鍵です
  2. ブラウザ履歴の管理: 戻る・進むボタンが正常に動作するよう、イベントハンドリングを適切に実装しましょう
  3. 動的なタイトル変更: SEO とユーザビリティの両方を向上させる重要な要素です
  4. ローディング状態の管理: ユーザーに処理状況を適切に伝えることで、信頼性を向上させます
  5. エラーハンドリング: 予期しない状況でもユーザーを迷わせない配慮が大切です
  6. パフォーマンス最適化: キャッシュ戦略や遅延読み込みで、高速な体験を提供しましょう

次のステップ

htmx の history 拡張をマスターしたら、以下のような発展的な機能にも挑戦してみてください:

  • WebSocket との連携: リアルタイム更新機能の実装
  • フォームバリデーション: クライアントサイドとサーバーサイドの連携
  • アニメーション: ページ遷移時のスムーズなアニメーション効果
  • オフライン対応: Service Worker との連携

htmx の history 拡張は、Web 開発の新しい可能性を開く技術です。この記事で学んだ知識を活かして、ユーザーが愛用する Web アプリケーションを作り上げてください。

関連リンク