htmx 逆引きレシピ
どれを選ぶ?(polling/SSE/WS)

公開日:
最終更新日:

リアルタイム系のUIを作るときは、「まず何を選ぶか」を決める方が実装より重要です。

このページでは polling / SSE / WebSocket を最小構成で並べ、選定の目安を先に掴めるようにします。

使用するhtmx属性

タグ:hx-trigger / hx-sse / ws-connect / ws-send

  • hx-triggerevery 3s のように定期取得を簡単に書けます(polling)。
  • hx-sse:SSEの接続先を示す属性として使えます(このページでは sse-connect も併記)。
  • ws-connect:WebSocket接続を開始します。
  • ws-send:フォーム送信をWebSocket経由で送ります。

利用シーン

  • 数秒に1回で十分(在庫/集計/監視):多少遅れても問題ないので、実装を軽くして“安定優先”で更新したい
  • 更新が来たら即反映(通知/チャット):サーバ側のイベント発生に合わせて押し出し、待ち時間なくUIに反映したい
  • 双方向が必要(共同編集/ゲーム):クライアント→サーバも頻繁に送りたいので、低遅延な双方向通信で同期したい

選び方の比較表(簡易)

Polling

  • 導入が最も簡単
  • 遅延は間隔依存
  • 定期監視に強い

SSE

  • サーバ→クライアント即時
  • 通知/更新配信に向く
  • 片方向のみ

WebSocket

  • 双方向で低遅延
  • 運用難度は高め
  • チャット/共同編集向き
方式 遅延 実装難度 サーバ負荷 双方向 向いてる用途
Polling 中(間隔依存) 中〜高(頻度次第) なし 在庫・集計・監視
SSE 中(接続維持) なし(片方向) 通知・一斉更新
WebSocket 中〜高 中(常時接続管理) あり チャット・共同編集・ゲーム

共通PHP(全文)

出力用の htmlspecialchars_which_to_choose_data.php に集約しています。

PHP(_which_to_choose_data.php)

<?php
// 型を厳密に扱う
declare(strict_types=1);

// HTML特殊文字を安全化する
function which_to_choose_h(string $value): string
{
	// エスケープ済み文字列を返す
	return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}

// 現在時刻を返す
function which_to_choose_now(): string
{
	// 時刻文字列を返す
	return date('H:i:s');
}

// 擬似ステータス候補を返す
function which_to_choose_statuses(): array
{
	// ステータス配列を返す
	return ['OK', 'BUSY', 'WARN', 'IDLE'];
}

// カウンタから擬似ステータスを返す
function which_to_choose_pick_status(int $counter): string
{
	// 候補配列を取得する
	$statuses = which_to_choose_statuses();

	// 配列の件数を取得する
	$count = count($statuses);

	// 件数が0なら既定値を返す
	if ($count === 0) {
		// 既定値を返す
		return 'OK';
	}

	// インデックスを計算する
	$index = $counter % $count;

	// ステータスを返す
	return (string)$statuses[$index];
}

// ステータスに応じた表示ラベルを返す
function which_to_choose_status_label(string $status): string
{
	// ステータスを大文字へ正規化する
	$status = strtoupper(trim($status));

	// OKを処理する
	if ($status === 'OK') {
		// 表示ラベルを返す
		return '正常';
	}

	// BUSYを処理する
	if ($status === 'BUSY') {
		// 表示ラベルを返す
		return '混雑';
	}

	// WARNを処理する
	if ($status === 'WARN') {
		// 表示ラベルを返す
		return '注意';
	}

	// IDLEを処理する
	if ($status === 'IDLE') {
		// 表示ラベルを返す
		return '待機';
	}

	// 既定値を返す
	return '不明';
}

① Polling(数秒に1回で十分)

hx-trigger="every 3s" で「時刻」「カウンタ」「擬似ステータス」を定期取得します。

HTML(view全文)

<div class="DEMO">

	<h4>DEMO1: Polling(数秒に1回で十分)</h4>

	<p class="HTMX-NOTE">
		在庫/集計/監視のように「数秒遅れても問題ない」用途向けです。
	</p>

	<div class="CARD">

		<div
			aria-hidden="true"
			style="display:none;"
			hx-get="/htmx/demo/_which_to_choose_1.php"
			hx-trigger="load, every 3s"
			hx-target="#DEMO_WHICH_POLL_RESULT"
			hx-swap="innerHTML"
		></div>

		<div id="DEMO_WHICH_POLL_RESULT" class="FORM-RESULT">
			<p class="HTMX-NOTE">3秒ごとに最新状態を取得します。</p>
		</div>

	</div>

</div>

PHP(_which_to_choose_1.php)

<?php
// 型を厳密に扱う
declare(strict_types=1);

// セッションを開始する
session_start();

// HTML断片として返す
header('Content-Type: text/html; charset=UTF-8');

// 共通関数を読み込む
require_once(__DIR__ . '/_which_to_choose_data.php');

// カウンタキーを定義する
$counterKey = 'which_to_choose_demo1_counter';

// カウンタが無ければ初期化する
if (!isset($_SESSION[$counterKey])) {
	// カウンタを0で初期化する
	$_SESSION[$counterKey] = 0;
}

// カウンタを加算する
$_SESSION[$counterKey] = (int)$_SESSION[$counterKey] + 1;

// カウンタ値を取り出す
$counter = (int)$_SESSION[$counterKey];

// 擬似ステータスを作る
$status = which_to_choose_pick_status($counter);

// 表示ラベルを作る
$statusLabel = which_to_choose_status_label($status);

// 表示用時刻を作る
$time = which_to_choose_now();

// カウンタを安全化する
$counterEsc = which_to_choose_h((string)$counter);

// ステータスを安全化する
$statusEsc = which_to_choose_h($status);

// 表示ラベルを安全化する
$statusLabelEsc = which_to_choose_h($statusLabel);

// 時刻を安全化する
$timeEsc = which_to_choose_h($time);

// 結果コンテナを開始する
echo '<div class="FORM-RESULT is-ok">';

// 見出しを返す
echo '<p><strong>Polling結果</strong>(3秒ごと)</p>';

// 時刻行を返す
echo '<p><strong>時刻</strong>: ' . $timeEsc . '</p>';

// カウンタ行を返す
echo '<p><strong>カウンタ</strong>: #' . $counterEsc . '</p>';

// ステータス行を返す
echo '<p><strong>擬似ステータス</strong>: ' . $statusEsc . '(' . $statusLabelEsc . ')</p>';

// 補足を返す
echo '<p class="HTMX-NOTE">数秒に1回で十分な画面は polling が最短で実装できます。</p>';

// 結果コンテナを閉じる
echo '</div>';

デモ

DEMO1: Polling(数秒に1回で十分)

在庫/集計/監視のように「数秒遅れても問題ない」用途向けです。

3秒ごとに最新状態を取得します。

② SSE(更新が来たら即反映)

event: message を受信して、イベント行を追加します。デモ用に数回送って終了します。

HTML(view全文)

<div class="DEMO">

	<h4>DEMO2: SSE(更新が来たら即反映)</h4>

	<p class="HTMX-NOTE">
		通知や配信のように「更新が来た瞬間に反映したい」用途向けです。
	</p>

	<div id="DEMO_WHICH_SSE_MOUNT" class="CARD">
		<div
			hx-get="/htmx/demo/_which_to_choose_2.php"
			hx-trigger="load"
			hx-target="#DEMO_WHICH_SSE_MOUNT"
			hx-swap="innerHTML"
		></div>
		<p class="HTMX-NOTE">接続中...</p>
	</div>

</div>

PHP(_which_to_choose_2.php)

<?php
// 型を厳密に扱う
declare(strict_types=1);

// HTML断片として返す
header('Content-Type: text/html; charset=UTF-8');

// 共通関数を読み込む
require_once(__DIR__ . '/_which_to_choose_data.php');

// 接続URLを定義する
$connectUrl = '/htmx/demo/_which_to_choose_sse.php';

// 接続URLを安全化する
$connectUrlEsc = which_to_choose_h($connectUrl);

// ルートコンテナを開始する
echo '<div id="DEMO_WHICH_SSE_ROOT" class="CARD" hx-ext="sse" sse-connect="' . $connectUrlEsc . '" sse-close="done">';

// 見出しを返す
echo '<p><strong>SSE受信ログ</strong>(event: message)</p>';

// 補足を返す
echo '<p class="HTMX-NOTE">デモ用に数回受信してストリームは終了します。</p>';

// 受信領域を開始する
echo '<ul id="DEMO_WHICH_SSE_LIST" class="HTMX-LIST" sse-swap="message" hx-swap="afterbegin">';

// 初期行を返す
echo '<li class="HTMX-NOTE">ここにSSEイベントが追加されます。</li>';

// 受信領域を閉じる
echo '</ul>';

// ルートコンテナを閉じる
echo '</div>';

PHP(_which_to_choose_sse.php)

<?php
// 型を厳密に扱う
declare(strict_types=1);

// 実行時間制限を解除する
set_time_limit(0);

// セッションロックがあれば解放する
if (session_status() === PHP_SESSION_ACTIVE) {
	// セッション書き込みを閉じる
	session_write_close();
}

// 共通関数を読み込む
require_once(__DIR__ . '/_which_to_choose_data.php');

// SSEのMIMEタイプを返す
header('Content-Type: text/event-stream; charset=utf-8');

// キャッシュを無効化する
header('Cache-Control: no-cache');

// 接続維持ヘッダを返す
header('Connection: keep-alive');

// バッファリング抑制ヘッダを返す
header('X-Accel-Buffering: no');

// gzip圧縮を無効化する
@ini_set('zlib.output_compression', '0');

// 出力バッファをできるだけ閉じる
while (ob_get_level() > 0) {
	// 出力バッファを1段閉じる
	@ob_end_flush();
}

// 再接続間隔を通知する
echo "retry: 2500\n\n";

// 初回をflushする
@flush();

// 擬似イベントを20回送る
for ($i = 1; $i <= 20; $i++) {
	// 表示時刻を作る
	$time = which_to_choose_now();

	// ステータスを作る
	$status = which_to_choose_pick_status($i);

	// ステータスラベルを作る
	$label = which_to_choose_status_label($status);

	// HTML行を1行で組み立てる
	$html = '<li><strong>#' . which_to_choose_h((string)$i) . '</strong> ' . which_to_choose_h($time) . ' / ' . which_to_choose_h($status) . '(' . which_to_choose_h($label) . ')</li>';

	// イベント名を返す
	echo "event: message\n";

	// データを返す
	echo "data: " . $html . "\n\n";

	// 送信をflushする
	@flush();

	// 1秒待機する
	sleep(1);
}

// 完了メッセージを作る
$done = '<li class="HTMX-NOTE">SSEデモの送信を終了しました(再読み込みで再開)。</li>';

// 表示用(message)を送る
echo "event: message\n";
echo "data: " . $done . "\n\n";
@flush();

// 終了用(done)を送る
echo "event: done\n";
echo "data: ok\n\n";
@flush();

// 終了
exit;

デモ

DEMO2: SSE(更新が来たら即反映)

通知や配信のように「更新が来た瞬間に反映したい」用途向けです。

接続中...

③ WebSocket(双方向)

接続できない環境向けに、自動フォールバック(polling)を用意しています。

HTML(view全文)

<div class="DEMO">

	<h4>DEMO3: WebSocket(双方向)</h4>

	<p class="HTMX-NOTE">
		共同編集やチャットのように双方向で即時同期したい用途向けです。接続失敗時は自動で polling へ切り替えます。
	</p>

	<div id="DEMO_WHICH_WS_MOUNT" class="CARD">
		<div
			hx-get="/htmx/demo/_which_to_choose_3.php"
			hx-trigger="load"
			hx-target="#DEMO_WHICH_WS_MOUNT"
			hx-swap="innerHTML"
		></div>
		<p class="HTMX-NOTE">読み込み中...</p>
	</div>

</div>

JavaScript(_ws_server_3.js)

const WebSocket = require("ws");

const PORT = process.env.PORT || 8080;
const wss = new WebSocket.Server({ port: PORT });

function broadcast(html) {
  for (const c of wss.clients) {
    if (c.readyState === WebSocket.OPEN) c.send(html);
  }
}

wss.on("connection", (ws) => {
  broadcast(`<span id="DEMO_WHICH_WS_STATE" hx-swap-oob="innerHTML">接続中</span>`);

  ws.on("message", (buf) => {
    const raw = buf.toString("utf8");

    // htmx ws-send は JSON を送るので message を拾う
    let payload;
    try { payload = JSON.parse(raw); } catch { payload = {}; }
    const msg = String(payload.message ?? raw).trim();

    const t = new Date().toLocaleTimeString("ja-JP", { hour12: false });
    const safe = msg.replace(/[&<>"']/g, s => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[s]));

    broadcast(
      `<div id="DEMO_WHICH_WS_LOG" hx-swap-oob="beforeend">` +
        `<div class="FORM-RESULT"><strong>受信</strong> <span class="HTMX-NOTE">${t}</span><br>${safe}</div>` +
      `</div>`
    );
  });
});

setInterval(() => {
  const t = new Date().toLocaleTimeString("ja-JP", { hour12: false });
  broadcast(`<span id="DEMO_WHICH_WS_TICK" hx-swap-oob="innerHTML">${t}</span>`);
}, 2000);

console.log(`WS server listening on ws://127.0.0.1:${PORT}`);

コマンド(ローカル環境)

cd /mochimochimikan.com/htmx/demo
npm init -y
npm i ws
node _ws_server_3.js

PHP(_which_to_choose_3.php)

<?php
// 型を厳密に扱う
declare(strict_types=1);

// HTML断片として返す
header('Content-Type: text/html; charset=UTF-8');

// 共通関数を読み込む
require_once(__DIR__ . '/_which_to_choose_data.php');

// 既定の接続先を作る
$wsEndpoint = 'ws://127.0.0.1:8080';

// HTTPS環境ならwssへ切り替える
if ((string)($_SERVER['HTTPS'] ?? '') !== '' && (string)($_SERVER['HTTPS'] ?? '') !== 'off') {
	// secure endpointを設定する
	$wsEndpoint = 'wss://127.0.0.1:8080';
}

// 接続先を安全化する
$wsEndpointEsc = which_to_choose_h($wsEndpoint);

// ルートを開始する
echo '<div id="DEMO_WHICH_WS_ROOT" class="CARD" hx-ext="ws" ws-connect="' . $wsEndpointEsc . '">';

// タイトル行を開始する
echo '<div class="FLEX FLEX_COLUMN ROWGAP16">';

// 接続状態を返す
echo '<p><strong>接続状態</strong>: <span id="DEMO_WHICH_WS_STATE" class="HTMX-NOTE">未接続</span></p>';

echo '<p><strong>サーバ時刻</strong>: <span id="DEMO_WHICH_WS_TICK" class="HTMX-NOTE">-</span></p>';

// 説明を返す
echo '<p class="HTMX-NOTE">ws-sendで送信します。サーバが無い環境では接続失敗し、下に疑似WSフォールバックを表示します。</p>';

// フォームを開始する
echo '<form id="DEMO_WHICH_WS_FORM" class="FORM" ws-send>';

// hidden種別を返す
echo '<input type="hidden" name="kind" value="chat">';

// ラベルを開始する
echo '<label>';

// ラベル文言を返す
echo 'メッセージ';

// 入力欄を返す
echo '<input type="text" name="message" maxlength="64" placeholder="例: 共同編集の変更を送信">';

// ラベルを閉じる
echo '</label>';

// ボタンを返す
echo '<button class="BTN" type="submit">ws-send で送信</button>';

// フォームを閉じる
echo '</form>';


// 送受信ログ領域を返す(追加)
echo '<div id="DEMO_WHICH_WS_LOG" class="FORM-RESULT MT1rm">';
echo '<span class="HTMX-NOTE">送信/受信ログがここに表示されます。</span>';
echo '</div>';


// ヒント領域を開始する
echo '<div id="DEMO_WHICH_WS_HINT">';

// ヒント取得トリガを返す
echo '<div hx-get="/htmx/demo/_which_to_choose_ws.php" hx-trigger="load" hx-target="#DEMO_WHICH_WS_HINT" hx-swap="innerHTML"></div>';

// ヒント領域を閉じる
echo '</div>';

// フォールバック領域を開始する
echo '<div id="DEMO_WHICH_WS_FALLBACK" hidden>';

// フォールバック見出しを返す
echo '<p><strong>フォールバック(疑似WS)</strong></p>';

// フォールバックトリガを返す
echo '<div aria-hidden="true" style="display:none;" hx-get="/htmx/demo/_which_to_choose_poll.php" hx-trigger="load, every 4s" hx-target="#DEMO_WHICH_WS_FALLBACK_RESULT" hx-swap="innerHTML"></div>';

// フォールバック結果領域を返す
echo '<div id="DEMO_WHICH_WS_FALLBACK_RESULT" class="FORM-RESULT"><p class="HTMX-NOTE">接続失敗時にここへ反映します。</p></div>';

// フォールバック領域を閉じる
echo '</div>';

// ルート補助テキストを返す
echo '<p class="HTMX-NOTE">※ WebSocketサーバ実装は既存レシピ(/chap7-realtime/ws/)を参照してください。</p>';

// タイトル行を閉じる
echo '</div>';

// ルートを閉じる
echo '</div>';

// スクリプトを開始する
echo '<script>';

// IIFEを開始する
echo '(function () {';

// 多重初期化防止フラグを確認する
echo 'if (window.__DEMO_WHICH_WS_INITED) { return; }';

// 多重初期化防止フラグを立てる
echo 'window.__DEMO_WHICH_WS_INITED = true;';

// 状態要素を取得する
echo 'var state = document.getElementById("DEMO_WHICH_WS_STATE");';

// フォールバック要素を取得する
echo 'var fallback = document.getElementById("DEMO_WHICH_WS_FALLBACK");';

// 状態を接続中へ更新する関数を定義する
echo 'var showOpen = function () { if (state) { state.textContent = "接続中"; } if (fallback) { fallback.hidden = true; } };';

// 状態を未接続へ更新する関数を定義する
echo 'var showClosed = function (label) { if (state) { state.textContent = label; } if (fallback) { fallback.hidden = false; } };';

// 接続成功イベントを監視する
echo 'document.body.addEventListener("htmx:wsOpen", function () { showOpen(); });';

// 切断イベントを監視する
echo 'document.body.addEventListener("htmx:wsClose", function () { showClosed("切断"); });';

// エラーイベントを監視する
echo 'document.body.addEventListener("htmx:wsError", function () { showClosed("接続エラー"); });';

// 送信後に入力をクリアしてメッセージを表示
echo 'document.body.addEventListener("htmx:wsAfterSend", function (e) {';
echo '  if (!e.detail || !e.detail.elt || e.detail.elt.id !== "DEMO_WHICH_WS_FORM") { return; }';
echo '  var form = e.detail.elt;';
echo '  var input = form.querySelector(\'input[name="message"]\');';
echo '  var log = document.getElementById("DEMO_WHICH_WS_LOG");';
echo '  if (!input || !log) { return; }';

echo '  var msg = (input.value || "").trim();';
echo '  var t = new Date().toLocaleTimeString("ja-JP", { hour12: false });';

echo '  var esc = function (s) {';
echo '    return String(s).replace(/[&<>"\']/g, function (c) {';
echo '      return ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", "\\"":"&quot;", "\\\'":"&#39;" })[c];';
echo '    });';
echo '  };';

echo '  if (msg) {';
echo '    log.insertAdjacentHTML("beforeend",';
echo '      \'<div><strong>送信</strong> <span class="HTMX-NOTE">\' + esc(t) + \'</span><br>\' + esc(msg) + \'</div>\'';
echo '    );';
echo '  }';

echo '  input.value = "";';
echo '});';

// IIFEを閉じる
echo '}());';

// スクリプトを閉じる
echo '</script>';

PHP(_which_to_choose_poll.php)

<?php
// 型を厳密に扱う
declare(strict_types=1);

// セッションを開始する
session_start();

// HTML断片として返す
header('Content-Type: text/html; charset=UTF-8');

// 共通関数を読み込む
require_once(__DIR__ . '/_which_to_choose_data.php');

// フォールバック用カウンタキーを定義する
$counterKey = 'which_to_choose_demo3_fallback_counter';

// カウンタが無ければ初期化する
if (!isset($_SESSION[$counterKey])) {
	// カウンタを0で初期化する
	$_SESSION[$counterKey] = 0;
}

// カウンタを加算する
$_SESSION[$counterKey] = (int)$_SESSION[$counterKey] + 1;

// カウンタを取り出す
$counter = (int)$_SESSION[$counterKey];

// 状態を作る
$status = which_to_choose_pick_status($counter + 1);

// 時刻を作る
$time = which_to_choose_now();

// カウンタを安全化する
$counterEsc = which_to_choose_h((string)$counter);

// 状態を安全化する
$statusEsc = which_to_choose_h($status);

// 時刻を安全化する
$timeEsc = which_to_choose_h($time);

// コンテナを開始する
echo '<div class="FORM-RESULT">';

// 見出しを返す
echo '<p><strong>疑似WSフォールバック(polling)</strong></p>';

// カウンタ行を返す
echo '<p><strong>tick</strong>: ' . $counterEsc . '</p>';

// 時刻行を返す
echo '<p><strong>時刻</strong>: ' . $timeEsc . '</p>';

// ステータス行を返す
echo '<p><strong>状態</strong>: ' . $statusEsc . '</p>';

// 補足を返す
echo '<p class="HTMX-NOTE">WS未接続時は4秒ごとにこの領域が更新されます。</p>';

// コンテナを閉じる
echo '</div>';

PHP(_which_to_choose_ws.php)

<?php
// 型を厳密に扱う
declare(strict_types=1);

// HTML断片として返す
header('Content-Type: text/html; charset=UTF-8');

// 共通関数を読み込む
require_once(__DIR__ . '/_which_to_choose_data.php');

// 既定の接続先を作る
$wsEndpoint = 'ws://127.0.0.1:8080';

// HTTPS環境ならwssへ切り替える
if ((string)($_SERVER['HTTPS'] ?? '') !== '' && (string)($_SERVER['HTTPS'] ?? '') !== 'off') {
	// secure endpointを設定する
	$wsEndpoint = 'wss://127.0.0.1:8080';
}

// 接続先を安全化する
$wsEndpointEsc = which_to_choose_h($wsEndpoint);

// コンテナを開始する
echo '<div class="FORM-RESULT">';

// 見出しを返す
echo '<p><strong>WS接続先</strong>: <code>' . $wsEndpointEsc . '</code></p>';

// 補足を返す
echo '<p class="HTMX-NOTE">WSサーバ未起動なら接続エラーになり、下のフォールバックが表示されます。</p>';

// コンテナを閉じる
echo '</div>';

デモ

DEMO3: WebSocket(双方向)

共同編集やチャットのように双方向で即時同期したい用途向けです。接続失敗時は自動で polling へ切り替えます。

読み込み中...

解説

※このデモはWebSocketサーバの常駐が必要です。レンタルサーバではWebSocketのプロキシ設定や常駐プロセスが使えない場合があるため、 本ページはローカル環境(HTTP)での実行を前提にしています。ローカルでは ws://localhost:8080 に接続して動作します。

次に読むオススメレシピ

このページの著者

もちもちみかん(システムエンジニア)

社内SEとしてグループ企業向けの業務アプリを要件定義〜運用まで一気通貫で担当しています。

経験:Webアプリ/業務システム

得意:PHP・JavaScript・MySQL・CSS

個人実績:フォーム生成基盤クイズ学習プラットフォーム

詳しいプロフィールはこちら!  もちもちみかんのプロフィール

もちもちみかん0系くん
TOPへ

もちもちみかん.comとは


このサイトでは、コーディングがめんどうくさい人向けのお助けツールとして、フォームやCSSをノーコードで生成できる、
 もちもちみかん.forms
 もちもちみかん.css1
 もちもちみかん.css2
と言ったジェネレーターを用意してます。

また、このサイトを通じて、「もちもちみかん」のかわいさを普及したいとかんがえてます!