T-CREATOR

htmx で作るチャット&メッセージング UI

htmx で作るチャット&メッセージング UI

Web チャット機能の実装といえば、React や Vue.js といった JavaScript フレームワークを使った複雑な仕組みが必要と思われがちですが、実は htmx を使えば驚くほどシンプルに実装できます。この記事では、htmx の持つ宣言的な特性を活かして、リアルタイム性を持つチャット UI を効率的に構築する方法をご紹介します。

JavaScript の知識を最小限に抑えながら、実際のプロダクションで使える品質のチャットアプリケーションを作ってみましょう。

背景

Web チャットの従来の実装方法の課題

現代の Web チャット開発では、多くの開発者が React、Vue.js、Angular といった JavaScript フレームワークに依存した実装を選択しています。しかし、これらのアプローチには以下のような課題が存在します。

mermaidflowchart TD
    A[従来のチャット実装] --> B[React/Vue.js]
    B --> C[複雑な状態管理]
    B --> D[大きなバンドルサイズ]
    B --> E[学習コストの高さ]
    C --> F[Redux/Vuex 必須]
    D --> G[初期表示の遅延]
    E --> H[開発期間の長期化]
    F --> I[コード複雑化]
    G --> J[ユーザー体験の悪化]
    H --> K[開発コストの増大]
    I --> K
    J --> K

図で理解できる要点:

  • 従来手法では複数の技術習得が必要
  • パフォーマンスとコストのトレードオフが発生
  • 結果的に開発効率の低下につながる

従来のアプローチでは、WebSocket の管理、コンポーネント間の状態共有、リアルタイム更新の同期など、チャット機能の本質とは異なる技術的な複雑さに多くの開発時間が費やされてしまいます。

加えて、JavaScript のバンドルサイズが大きくなりがちで、初回ページロードが遅くなる問題もあります。モバイル環境や通信速度が限られた環境では、この問題がユーザー体験の大幅な悪化につながることも少なくありません。

htmx によるリアルタイム通信の可能性

htmx は「HTML-first」の思想に基づいて設計されたライブラリで、HTML 属性を使って動的な Web アプリケーションを構築できます。チャット機能の実装において、htmx が提供する以下の機能が特に注目すべきポイントです。

Server-Sent Events(SSE)との統合 htmx は Server-Sent Events を標準でサポートしており、サーバーからのプッシュ通知を簡潔に実装できます。これにより、新着メッセージの通知やユーザーのオンライン状態の更新を、WebSocket よりもシンプルに実現できます。

宣言的な UI 更新 hx-triggerhx-swap といった属性を HTML に直接記述することで、ユーザーのアクション(メッセージ送信、スクロール等)に対する UI の動的更新を直感的に定義できます。

軽量なランタイム htmx のライブラリサイズは約 14KB(gzip 圧縮後)と非常に軽量で、React や Vue.js と比較して初期表示速度の大幅な改善が期待できます。

JavaScript フレームワーク不要のメリット

htmx を使用したチャット実装では、従来の JavaScript フレームワークが抱える問題を根本的に解決できます。

学習コストの大幅削減 HTML と CSS の基礎知識があれば、すぐにチャット機能の開発を始められます。複雑な状態管理ライブラリやビルドツールの習得は不要です。

メンテナンス性の向上 サーバーサイドで HTML を生成するため、ロジックが一箇所に集約され、デバッグや機能拡張が容易になります。フロントエンドとバックエンドの複雑な API 設計も必要ありません。

SEO とアクセシビリティの標準対応 サーバーサイドレンダリングが基本となるため、検索エンジン最適化やスクリーンリーダー対応が自然に実現されます。

課題

リアルタイム更新の実装難易度

htmx でチャット機能を実装する際、最初に直面するのがリアルタイム更新の仕組みです。従来の JavaScript フレームワークでは WebSocket ライブラリが豊富に存在しますが、htmx 環境では異なるアプローチが必要になります。

mermaidsequenceDiagram
    participant User1 as ユーザー1
    participant Server as サーバー
    participant User2 as ユーザー2

    User1->>Server: メッセージ送信
    Server->>Server: メッセージ保存
    Server->>User1: 送信完了レスポンス
    Server->>User2: 新着メッセージ通知
    User2->>Server: メッセージ一覧取得
    Server->>User2: 更新されたメッセージ一覧

図で理解できる要点:

  • 複数ユーザー間でのリアルタイム同期が必要
  • サーバーからのプッシュ通知機能が重要
  • 各ユーザーの UI 状態を適切に更新する必要

ポーリング vs Server-Sent Events の選択 リアルタイム更新を実現するため、定期的な HTTP リクエスト(ポーリング)か Server-Sent Events のどちらを採用するかの判断が必要です。ポーリングは実装が簡単ですが、サーバー負荷とレスポンス遅延が課題となります。

接続状態の管理 ユーザーがページを離れた際の接続切断処理や、ネットワーク障害からの自動復旧機能の実装に工夫が必要です。

UI の状態管理の複雑さ

チャット UI では、メッセージの表示状態、スクロール位置、入力フォームの状態など、複数の UI 状態を同時に管理する必要があります。

スクロール位置の制御 新着メッセージが届いた際、ユーザーが過去のメッセージを閲覧中の場合は自動スクロールを停止し、最新メッセージを読んでいる場合は自動で最下部にスクロールするといった、動的な制御が求められます。

メッセージ送信中の UI フィードバック メッセージ送信ボタンの無効化、送信中インジケーターの表示、送信失敗時のエラー表示など、ユーザーに適切なフィードバックを提供する仕組みが必要です。

既読・未読の視覚的表現 メッセージの既読状態を視覚的に表現し、リアルタイムで更新する機能も重要な要素です。

メッセージ送信とレスポンスの同期

チャット機能において最も重要な課題の一つが、メッセージ送信とサーバーレスポンスの適切な同期です。

楽観的 UI 更新(Optimistic UI)の実装 ユーザーがメッセージを送信した瞬間に UI を更新し、サーバーからの確認後に状態を確定する仕組みが必要です。送信に失敗した場合の適切なエラーハンドリングも重要です。

重複送信の防止 ネットワークの遅延やユーザーの誤操作による重複送信を防ぐ仕組みが必要です。特に、送信ボタンの連打や通信エラー時のリトライ処理に注意が必要です。

メッセージの順序保証 複数のユーザーが同時にメッセージを送信した場合、サーバー側での処理順序とクライアント側の表示順序を一致させる仕組みが求められます。

解決策

htmx の hx-trigger と hx-swap による動的更新

htmx の核となる機能である hx-triggerhx-swap を組み合わせることで、チャット UI の動的更新を効率的に実装できます。

mermaidflowchart LR
    A[ユーザーアクション] --> B[hx-trigger]
    B --> C[HTTP リクエスト]
    C --> D[サーバー処理]
    D --> E[HTML レスポンス]
    E --> F[hx-swap]
    F --> G[DOM 更新]
    G --> H[UI 反映]

図で理解できる要点:

  • ユーザーアクションから UI 更新までがシンプルなフロー
  • サーバーが HTML を生成することで状態管理が簡素化
  • JavaScript コードを書かずに動的 UI を実現

メッセージ送信フォームの実装例

メッセージ送信時の基本的な動作を定義します:

html<form
  hx-post="/messages"
  hx-target="#messages-container"
  hx-swap="beforeend"
  hx-on::after-request="this.reset()"
>
  <input
    type="text"
    name="message"
    placeholder="メッセージを入力..."
    required
  />
  <button type="submit">送信</button>
</form>

このコードでは以下の処理が自動的に実行されます:

  • フォーム送信時に ​/​messages エンドポイントに POST リクエスト
  • サーバーからのレスポンスを #messages-container の末尾に追加
  • 送信完了後にフォームをリセット

リアルタイム更新のトリガー設定

Server-Sent Events を利用したリアルタイム更新の設定:

html<div
  id="messages-container"
  hx-sse="connect:/messages/stream"
  hx-sse-swap="message"
  hx-swap="beforeend"
>
  <!-- メッセージがここに動的に追加される -->
</div>

hx-sse 属性により、サーバーからのイベントストリームに接続し、新着メッセージを自動的に UI に反映できます。

WebSocket との連携手法

htmx は標準で Server-Sent Events をサポートしていますが、双方向通信が必要な場合は WebSocket との連携も可能です。

htmx WebSocket 拡張の活用

htmx の WebSocket 拡張機能を使用した実装例:

html<div hx-ws="connect:ws://localhost:8080/chat">
  <form hx-ws="send">
    <input name="message" type="text" />
    <button type="submit">送信</button>
  </form>

  <div id="messages"></div>
</div>

WebSocket 接続とメッセージ送信を HTML 属性だけで実現できます。

カスタムイベントハンドラーの実装

より高度な制御が必要な場合は、JavaScript との組み合わせも可能です:

javascriptdocument.addEventListener(
  'htmx:wsConfigRequest',
  function (evt) {
    evt.detail.socket.onopen = function () {
      console.log('WebSocket 接続が確立されました');
    };

    evt.detail.socket.onclose = function () {
      console.log('WebSocket 接続が切断されました');
    };
  }
);

サーバーサイドでの状態管理

htmx アプローチの大きな利点は、UI 状態をサーバーサイドで一元管理できることです。

セッション管理とユーザー状態

Express.js を使用したサーバーサイド実装例:

javascript// ユーザーセッション管理
app.use(
  session({
    secret: 'chat-session-secret',
    resave: false,
    saveUninitialized: true,
  })
);

// オンラインユーザーの管理
const onlineUsers = new Map();

app.post('/messages', (req, res) => {
  const { message } = req.body;
  const userId = req.session.userId;

  // メッセージをデータベースに保存
  const newMessage = {
    id: Date.now(),
    userId: userId,
    message: message,
    timestamp: new Date(),
  };

  messages.push(newMessage);

  // 新着メッセージを他のユーザーに配信
  broadcastMessage(newMessage);

  // HTML レスポンスを返す
  res.send(`
    <div class="message">
      <strong>${getUserName(userId)}:</strong>
      <span>${message}</span>
      <small>${newMessage.timestamp.toLocaleTimeString()}</small>
    </div>
  `);
});

Server-Sent Events の実装

リアルタイム配信のためのサーバー実装:

javascriptapp.get('/messages/stream', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
  });

  const userId = req.session.userId;

  // クライアントをストリーミングリストに追加
  const client = { id: userId, response: res };
  clients.add(client);

  // 接続切断時の処理
  req.on('close', () => {
    clients.delete(client);
  });
});

function broadcastMessage(message) {
  const messageHtml = `
    <div class="message">
      <strong>${getUserName(message.userId)}:</strong>
      <span>${message.message}</span>
      <small>${message.timestamp.toLocaleTimeString()}</small>
    </div>
  `;

  clients.forEach((client) => {
    client.response.write(`event: message\n`);
    client.response.write(`data: ${messageHtml}\n\n`);
  });
}

具体例

基本的なメッセージ送信フォームの実装

チャット機能の基盤となるメッセージ送信フォームから実装を始めましょう。

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 チャットアプリ</title>
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
    <script src="https://unpkg.com/htmx.org@1.9.10/dist/ext/sse.js"></script>
  </head>
  <body>
    <div class="chat-container">
      <div class="header">
        <h1>htmx チャットルーム</h1>
        <div id="online-users"></div>
      </div>
    </div>
  </body>
</html>

基本的な HTML 構造では、htmx 本体と SSE 拡張をロードし、チャット UI の基本レイアウトを定義します。

メッセージ送信フォームの詳細実装

html<div class="messages-section">
  <div
    id="messages-container"
    class="messages-list"
    hx-sse="connect:/chat/stream"
    hx-sse-swap="message"
    hx-swap="beforeend scroll:bottom"
  >
    <!-- 既存メッセージがここに表示される -->
  </div>

  <form
    id="message-form"
    class="message-input"
    hx-post="/chat/messages"
    hx-target="#messages-container"
    hx-swap="beforeend scroll:bottom"
    hx-on::after-request="this.reset(); focusMessageInput()"
  >
    <div class="input-group">
      <input
        type="text"
        id="message-input"
        name="message"
        placeholder="メッセージを入力してください..."
        maxlength="500"
        required
        autocomplete="off"
      />

      <button
        type="submit"
        class="send-button"
        hx-indicator="#sending-indicator"
      >
        送信
      </button>
    </div>

    <div id="sending-indicator" class="htmx-indicator">
      送信中...
    </div>
  </form>
</div>

このフォーム実装のポイント:

  • hx-sse でリアルタイム受信を設定
  • scroll:bottom で自動スクロールを実現
  • hx-indicator で送信状態の視覚フィードバック
  • hx-on::after-request で送信後の処理

CSS スタイリング

css.chat-container {
  max-width: 800px;
  margin: 0 auto;
  height: 100vh;
  display: flex;
  flex-direction: column;
}

.messages-list {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  background-color: #f8f9fa;
  border: 1px solid #dee2e6;
}

.message-input {
  padding: 20px;
  background-color: white;
  border-top: 1px solid #dee2e6;
}

.input-group {
  display: flex;
  gap: 10px;
}

#message-input {
  flex: 1;
  padding: 12px;
  border: 1px solid #ced4da;
  border-radius: 25px;
  font-size: 16px;
}

.send-button {
  padding: 12px 24px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 25px;
  cursor: pointer;
  font-weight: bold;
}

.htmx-indicator {
  color: #6c757d;
  font-size: 14px;
  margin-top: 8px;
}

リアルタイムメッセージ受信の仕組み

Server-Sent Events を使用したリアルタイムメッセージ配信システムを実装します。

サーバーサイドの実装

Express.js でのストリーミングエンドポイント:

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

// アクティブなクライアント接続を管理
const sseClients = new Set();

// メッセージストリーミングエンドポイント
app.get('/chat/stream', (req, res) => {
  // SSE ヘッダーの設定
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Headers': 'Cache-Control'
  });

  // 接続開始メッセージ
  res.write(`data: <div class="system-message">チャットに接続しました</div>\n\n`);

  // クライアント情報を保存
  const clientInfo = {
    id: Date.now(),
    response: res,
    userId: req.session?.userId || 'anonymous'
  };

  sseClients.add(clientInfo);

メッセージ配信関数の実装

javascript// 全クライアントにメッセージを配信
function broadcastMessage(messageData) {
  const messageHtml = generateMessageHtml(messageData);

  sseClients.forEach((client) => {
    try {
      client.response.write(`event: message\n`);
      client.response.write(`data: ${messageHtml}\n\n`);
    } catch (error) {
      // 切断されたクライアントを削除
      sseClients.delete(client);
    }
  });
}

// HTML メッセージの生成
function generateMessageHtml(messageData) {
  const { userId, message, timestamp, messageId } =
    messageData;
  const userName = getUserName(userId);
  const timeString = new Date(timestamp).toLocaleTimeString(
    'ja-JP'
  );

  return `
    <div class="message" data-message-id="${messageId}">
      <div class="message-header">
        <span class="user-name">${userName}</span>
        <span class="timestamp">${timeString}</span>
      </div>
      <div class="message-content">${escapeHtml(
        message
      )}</div>
    </div>
  `;
}

// XSS 対策のための HTML エスケープ
function escapeHtml(text) {
  const map = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;',
  };
  return text.replace(/[&<>"']/g, (char) => map[char]);
}

接続管理とエラーハンドリング

javascript// クライアント接続の継続処理
app.get('/chat/stream', (req, res) => {
  // ... 前述のコード ...

  // 定期的なハートビート送信(30秒間隔)
  const heartbeat = setInterval(() => {
    try {
      res.write(`event: heartbeat\n`);
      res.write(`data: ping\n\n`);
    } catch (error) {
      clearInterval(heartbeat);
      sseClients.delete(clientInfo);
    }
  }, 30000);

  // クライアント切断時の処理
  req.on('close', () => {
    clearInterval(heartbeat);
    sseClients.delete(clientInfo);
    console.log(`Client ${clientInfo.id} disconnected`);
  });

  req.on('error', (error) => {
    console.error('SSE connection error:', error);
    clearInterval(heartbeat);
    sseClients.delete(clientInfo);
  });
});

ユーザー状態表示の実装

オンライン状態やタイピングインジケーターなど、ユーザーの活動状態を表示する機能を実装します。

mermaidstateDiagram-v2
    [*] --> オフライン
    オフライン --> オンライン : 接続
    オンライン --> タイピング中 : 入力開始
    タイピング中 --> オンライン : 入力停止/送信
    オンライン --> オフライン : 切断
    タイピング中 --> オフライン : 切断

図で理解できる要点:

  • ユーザー状態は 3 つの基本状態で管理
  • 状態変更は明確なトリガーで発生
  • 切断時は全ての状態がオフラインにリセット

オンラインユーザー表示

html<!-- ヘッダー部分にオンラインユーザー表示エリア -->
<div class="header">
  <h1>htmx チャットルーム</h1>
  <div
    id="online-users"
    hx-sse-swap="user-status"
    class="online-users-list"
  >
    <!-- オンラインユーザーがここに表示される -->
  </div>
</div>

サーバーサイドのユーザー状態管理

javascript// ユーザー状態の管理
const userStates = new Map();

// ユーザー接続時の処理
function handleUserConnection(userId, clientInfo) {
  userStates.set(userId, {
    status: 'online',
    lastActivity: new Date(),
    clientInfo: clientInfo,
  });

  // 他のユーザーに状態変更を通知
  broadcastUserStatus();
}

// ユーザー状態の配信
function broadcastUserStatus() {
  const onlineUsers = Array.from(userStates.entries())
    .filter(([_, state]) => state.status === 'online')
    .map(([userId, state]) => ({
      userId,
      userName: getUserName(userId),
      status: state.status,
      lastActivity: state.lastActivity,
    }));

  const statusHtml = generateUserStatusHtml(onlineUsers);

  sseClients.forEach((client) => {
    try {
      client.response.write(`event: user-status\n`);
      client.response.write(`data: ${statusHtml}\n\n`);
    } catch (error) {
      sseClients.delete(client);
    }
  });
}

// ユーザー状態 HTML の生成
function generateUserStatusHtml(users) {
  if (users.length === 0) {
    return '<div class="no-users">現在オンラインのユーザーはいません</div>';
  }

  const userListHtml = users
    .map(
      (user) => `
    <div class="online-user" data-user-id="${user.userId}">
      <span class="status-indicator online"></span>
      <span class="user-name">${user.userName}</span>
    </div>
  `
    )
    .join('');

  return `
    <div class="online-users-header">
      オンライン (${users.length}人)
    </div>
    <div class="users-list">
      ${userListHtml}
    </div>
  `;
}

タイピングインジケーターの実装

html<!-- メッセージ入力フィールドにタイピング検知を追加 -->
<input
  type="text"
  id="message-input"
  name="message"
  placeholder="メッセージを入力してください..."
  hx-trigger="keyup changed delay:500ms"
  hx-post="/chat/typing"
  hx-swap="none"
  hx-vals='{"action": "typing"}'
  autocomplete="off"
/>

タイピング状態のサーバー処理

javascript// タイピング状態の管理
const typingUsers = new Map();

app.post('/chat/typing', (req, res) => {
  const userId = req.session.userId;
  const { action } = req.body;

  if (action === 'typing') {
    // タイピング状態を設定(3秒後に自動解除)
    typingUsers.set(userId, Date.now());

    setTimeout(() => {
      if (typingUsers.has(userId)) {
        typingUsers.delete(userId);
        broadcastTypingStatus();
      }
    }, 3000);

    broadcastTypingStatus();
  }

  res.status(200).send('');
});

// タイピング状態の配信
function broadcastTypingStatus() {
  const currentlyTyping = Array.from(
    typingUsers.keys()
  ).map((userId) => getUserName(userId));

  let statusHtml = '';
  if (currentlyTyping.length > 0) {
    const names = currentlyTyping.join(', ');
    statusHtml = `
      <div id="typing-indicator" class="typing-status">
        ${names} が入力中...
      </div>
    `;
  }

  sseClients.forEach((client) => {
    try {
      client.response.write(`event: typing-status\n`);
      client.response.write(`data: ${statusHtml}\n\n`);
    } catch (error) {
      sseClients.delete(client);
    }
  });
}

メッセージ履歴の動的読み込み

大量のメッセージ履歴を効率的に読み込むための無限スクロール機能を実装します。

履歴読み込みの HTML 設定

html<div id="messages-container" class="messages-list">
  <!-- 履歴読み込みトリガー -->
  <div
    id="load-more-trigger"
    hx-get="/chat/messages/history"
    hx-trigger="revealed"
    hx-target="#messages-container"
    hx-swap="afterbegin"
    hx-vals='{"before": "latest"}'
    class="load-more"
  >
    <div class="loading-spinner">履歴を読み込み中...</div>
  </div>

  <!-- メッセージ一覧がここに表示 -->
  <div id="messages-list">
    <!-- 既存メッセージ -->
  </div>
</div>

サーバーサイドの履歴取得

javascriptapp.get('/chat/messages/history', async (req, res) => {
  const { before, limit = 20 } = req.query;

  try {
    // データベースから履歴メッセージを取得
    const messages = await getMessageHistory({
      before:
        before === 'latest' ? Date.now() : parseInt(before),
      limit: parseInt(limit),
    });

    if (messages.length === 0) {
      return res.send(`
        <div class="no-more-messages">
          これ以上古いメッセージはありません
        </div>
      `);
    }

    // メッセージ HTML の生成
    const messagesHtml = messages
      .map((msg) => generateMessageHtml(msg))
      .join('');

    // 次の読み込み用トリガーを含める
    const nextTrigger =
      messages.length === parseInt(limit)
        ? `
      <div id="load-more-trigger"
           hx-get="/chat/messages/history"
           hx-trigger="revealed"
           hx-target="#messages-container"
           hx-swap="afterbegin"
           hx-vals='{"before": "${
             messages[messages.length - 1].timestamp
           }"}'
           class="load-more">
        <div class="loading-spinner">履歴を読み込み中...</div>
      </div>
    `
        : '';

    res.send(nextTrigger + messagesHtml);
  } catch (error) {
    console.error('履歴取得エラー:', error);
    res.status(500).send(`
      <div class="error-message">
        履歴の読み込みに失敗しました
      </div>
    `);
  }
});

データベース操作の実装

javascript// メッセージ履歴取得関数(例:MongoDB使用)
async function getMessageHistory({ before, limit }) {
  try {
    const messages = await db
      .collection('messages')
      .find({
        timestamp: { $lt: before },
        deleted: { $ne: true },
      })
      .sort({ timestamp: -1 })
      .limit(limit)
      .toArray();

    // 時系列順(古い順)に並び替え
    return messages.reverse();
  } catch (error) {
    console.error('Database query error:', error);
    throw error;
  }
}

まとめ

htmx を使用したチャット&メッセージング UI の実装は、従来の JavaScript フレームワークを使った開発と比較して、大幅な開発効率の向上とメンテナンス性の改善を実現できます。

主要な実装メリット

  • HTML 属性だけでリアルタイム機能を実装
  • サーバーサイドでの一元的な状態管理
  • 軽量で高速なユーザー体験
  • SEO とアクセシビリティの標準対応

技術的な特徴

  • Server-Sent Events による効率的なリアルタイム通信
  • 宣言的な UI 更新による保守性の向上
  • プログレッシブエンハンスメントによる堅牢性
  • 最小限の JavaScript での高機能実装

本記事で紹介した実装パターンを活用することで、複雑な JavaScript フレームワークを学習することなく、プロダクション品質のチャットアプリケーションを構築できます。htmx のシンプルさと強力さを体験し、新しい Web 開発のアプローチを検討してみてください。

関連リンク