htmx 逆引きレシピ
SSEで通知を流すには?
「承認依頼が来た」「ジョブが終わった」「未読が増えた」──こうした通知は、ページをリロードせずに自動で反映できると体験が一気に良くなります。
htmxなら、SSE(Server-Sent Events)を使って、サーバからの通知を“流すだけ”でUIを更新できます。
このページでは、承認依頼・ジョブ完了・未読件数の3種類を、1つのSSE接続で同時に受け取り、必要な場所だけ差し替える実装を紹介します。
さらに、必要ならNotification APIでブラウザ通知も出せるようにして、リアルタイム感を手軽に追加します。
使用するhtmx属性
hx-ext:拡張機能を有効化(このレシピではsse拡張を使います)sse-connect:SSEの接続先URLを指定(サーバがtext/event-streamを返す)sse-swap:受け取るSSEイベント名を指定し、そのイベントが来たら要素を差し替えるhx-swap:差し替え方を指定(例:通知を積むならafterbegin、バッジ更新ならouterHTML)hx-trigger:接続ブロックの読み込み・切断/再接続ボタンなど、SSE周辺の操作を発火させる
利用シーン
- 「承認依頼が来た」:新しい承認依頼を即座に一覧へ追加し、見逃しを防ぎたい
- 「ジョブ完了通知」:バックグラウンド処理の完了を受け取り、完了を確実に伝えたい
- 「未読件数の更新」:未読バッジをリアルタイムに近い感覚で更新し、状態のズレを減らしたい
「承認依頼が来た / ジョブ完了通知 / 未読件数の更新」
共通:1つのSSE接続で、複数イベントを受け取る
このデモでは、hx-ext="sse" を付けたコンテナに sse-connect を指定し、サーバのSSEストリームへ接続します。
サーバ側は event: approval / event: job_done / event: unread_count のようにイベント名を付けて配信し、クライアント側は sse-swap でイベントごとに差し替え先を分けます。
HTML
<div class="DEMO">
<h4>「承認依頼が来た / ジョブ完了通知 / 未読件数の更新」</h4>
<div id="DEMO_SSE_MOUNT" class="CARD">
<div
hx-get="/htmx/demo/_sse_notify_block_on.php"
hx-trigger="load"
hx-target="#DEMO_SSE_MOUNT"
hx-swap="innerHTML"
></div>
<p class="HTMX-NOTE">読み込み中…</p>
</div>
</div>
PHP(接続中ブロック/_sse_notify_block_on.php)
<div
id="DEMO_SSE_CONNECT"
class="CARD"
hx-ext="sse"
sse-connect="/htmx/demo/_sse_notify_stream.php"
>
<div class="ROW" style="justify-content:space-between;">
<div class="ROW">
<strong>接続ステータス:</strong>
<span id="DEMO_SSE_STATUS" class="BADGE">接続中…</span>
<span class="HTMX-NOTE" style="margin-left:.25rem;">
(SSEは長いHTTP接続です)
</span>
</div>
<div class="ROW">
<button
class="BTN is-sub"
hx-get="/htmx/demo/_sse_notify_block_off.php"
hx-target="#DEMO_SSE_MOUNT"
hx-swap="innerHTML"
type="button"
>
切断する
</button>
<button id="DEMO_SSE_NOTIFY_BTN" class="BTN is-ok" type="button">
🔔 通知をON
</button>
<span id="DEMO_SSE_NOTIFY_STATE" class="BADGE is-warn">通知:OFF</span>
</div>
</div>
<hr style="border:none; border-top:1px dashed rgba(0,0,0,.12); margin:1rem 0;">
<div class="GRID">
<!-- ① 承認依頼 -->
<section>
<h3 class="TTL">① 承認依頼が来た</h3>
<p class="HTMX-NOTE">
SSEの <code>event: approval</code> を受け取ったら、<code>afterbegin</code> で先頭に追加します。
</p>
<ul
id="DEMO_SSE_APPROVAL_LIST"
class="SSE-LIST"
sse-swap="approval"
hx-swap="afterbegin"
>
<li class="HTMX-NOTE">(まだ通知はありません)</li>
</ul>
</section>
<!-- ② ジョブ完了 -->
<section>
<h3 class="TTL">② ジョブ完了通知</h3>
<p class="HTMX-NOTE">
完了イベントを受け取ったら履歴に追加します。通知ONならブラウザ通知も出します。
</p>
<ul
id="DEMO_SSE_JOB_LIST"
class="SSE-LIST"
sse-swap="job_done"
hx-swap="afterbegin"
>
<li class="HTMX-NOTE">(まだ通知はありません)</li>
</ul>
</section>
<!-- ③ 未読件数 -->
<section>
<h3 class="TTL">③ 未読件数の更新</h3>
<p class="HTMX-NOTE">
バッジの <code>outerHTML</code> を差し替えて、未読件数を更新します。
</p>
<div style="margin-top:.6rem;">
<span
id="DEMO_SSE_UNREAD_BADGE"
class="BADGE is-warn"
sse-swap="unread_count"
hx-swap="outerHTML"
>未読:0</span>
</div>
<p class="HTMX-NOTE" style="margin-top:.6rem;">
※ Notification API は <strong>HTTPS か localhost</strong> など「安全なコンテキスト」でないと動かない環境があります。
</p>
</section>
</div>
</div>
<script>
(() => {
// 多重登録防止(ブロックを差し替えた時にイベントが二重になるのを防ぎます)
if (window.__DEMO_SSE_NOTIFY_INITED) return;
window.__DEMO_SSE_NOTIFY_INITED = true;
// 通知ON/OFFを保存するキー
const KEY = "DEMO_SSE_NOTIFY_ENABLED";
// 通知ボタン/状態
const btn = document.getElementById("DEMO_SSE_NOTIFY_BTN");
const state = document.getElementById("DEMO_SSE_NOTIFY_STATE");
// 接続ステータス
const sseStatus = document.getElementById("DEMO_SSE_STATUS");
// リストの上限(増えすぎ防止)
const trimList = (listId, keep = 12) => {
const ul = document.getElementById(listId);
if (!ul) return;
const items = ul.querySelectorAll("li");
if (items.length <= keep) return;
for (let i = keep; i < items.length; i++) items[i].remove();
};
// 通知のUI反映
const refreshNotifyUI = () => {
const enabled = localStorage.getItem(KEY) === "1";
state.textContent = enabled ? "通知:ON" : "通知:OFF";
state.classList.toggle("is-ok", enabled);
state.classList.toggle("is-warn", !enabled);
btn.textContent = enabled ? "🔕 通知をOFF" : "🔔 通知をON";
};
// 通知を出す
const notify = (title, body) => {
const enabled = localStorage.getItem(KEY) === "1";
if (!enabled) return;
if (!("Notification" in window)) return;
if (Notification.permission !== "granted") return;
// 画面を見てる時はうるさいので、基本は「非表示時だけ」通知
// if (document.visibilityState === "visible") return;
try {
new Notification(title, { body });
} catch (e) {
// 失敗してもデモは継続
}
};
// ボタン:通知ON/OFF
btn.addEventListener("click", async () => {
if (!("Notification" in window)) {
alert("このブラウザは Notification API に対応していません。");
return;
}
// 既にONならOFFにする
if (localStorage.getItem(KEY) === "1") {
localStorage.setItem(KEY, "0");
refreshNotifyUI();
return;
}
// 許可要求(ユーザー操作が必要)
const perm = await Notification.requestPermission();
if (perm === "granted") {
localStorage.setItem(KEY, "1");
} else {
localStorage.setItem(KEY, "0");
}
refreshNotifyUI();
});
// SSE接続が開いたらステータス更新
document.body.addEventListener("htmx:sseOpen", (e) => {
// sse-connect を持つ要素(=接続元)
if (!e.detail || !e.detail.elt) return;
if (e.detail.elt.id !== "DEMO_SSE_CONNECT") return;
sseStatus.textContent = "接続中";
sseStatus.classList.add("is-ok");
});
// SSEエラー(接続不可/切断など)
document.body.addEventListener("htmx:sseError", (e) => {
sseStatus.textContent = "接続エラー";
sseStatus.classList.remove("is-ok");
});
// SSE受信後(detail は MessageEvent。type がイベント名になります)
document.body.addEventListener("htmx:sseMessage", (e) => {
if (!e.detail) return;
const eventName = e.detail.type; // "approval" / "job_done" / "unread_count" など
const data = e.detail.data || "";
// ①/②は増えすぎ防止でリストを間引く
if (eventName === "approval") trimList("DEMO_SSE_APPROVAL_LIST", 12);
if (eventName === "job_done") trimList("DEMO_SSE_JOB_LIST", 12);
// ブラウザ通知(見出し/本文は簡単に)
if (eventName === "approval") {
notify("承認依頼が届きました", "一覧に新しい承認依頼が追加されました。");
}
if (eventName === "job_done") {
notify("ジョブが完了しました", "バックグラウンド処理が完了しました。");
}
});
// 初期UI反映
refreshNotifyUI();
})();
</script>
PHP(切断中ブロック/_sse_notify_block_off.php)
<div class="CARD">
<p class="HTMX-NOTE">
SSEを切断しました。接続し直すと、通知が再開します。
</p>
<button
class="BTN is-ok"
hx-get="/htmx/demo/_sse_notify_block_on.php"
hx-trigger="click"
hx-target="#DEMO_SSE_MOUNT"
hx-swap="innerHTML"
type="button"
>
接続する
</button>
</div>
PHP(SSE本体/_sse_notify_stream.php)
<?php
// SSEは「終わらないレスポンス」なので、実行時間制限を解除します
set_time_limit(0);
// セッションを使っている場合、SSEでロックし続けないようにします(使うなら)
if (session_status() === PHP_SESSION_ACTIVE) {
// セッション書き込みを閉じてロック解除します
session_write_close();
}
// SSEのMIMEタイプを指定します
header('Content-Type: text/event-stream; charset=utf-8');
// キャッシュされると困るので無効化します
header('Cache-Control: no-cache');
// 接続維持のためのヘッダです(環境により効果は異なります)
header('Connection: keep-alive');
// Nginxなどのバッファリングを抑制します(環境により無視されます)
header('X-Accel-Buffering: no');
// gzip圧縮が有効だと即時flushされないことがあるため無効化します
@ini_set('zlib.output_compression', '0');
// PHP出力バッファを可能な限り閉じます
while (ob_get_level() > 0) {
// 出力バッファを1段閉じます
@ob_end_flush();
}
// flushを自動で行う設定にします
@ob_implicit_flush(true);
// 再接続間隔(ミリ秒)をクライアントへ通知します
echo "retry: 2000\n\n";
// すぐに送信します
@flush();
// 承認依頼IDの初期値を作ります
$approvalId = random_int(10000, 99999);
// ジョブIDの初期値を作ります
$jobId = random_int(1000, 9999);
// 未読件数の初期値を作ります
$unread = random_int(0, 12);
// デモ用に無限ループでイベントを流します
while (true) {
// 現在時刻(表示用)を作ります
$now = date('H:i:s');
// どのイベントを送るかをランダムに決めます
$type = random_int(1, 3);
// ① 承認依頼
if ($type === 1) {
// 承認依頼IDを進めます
$approvalId++;
// 依頼者候補を用意します
$users = ['tanaka', 'sato', 'suzuki', 'yamada'];
// 依頼者をランダムに選びます
$user = $users[array_rand($users)];
// 申請タイトル候補を用意します
$titles = ['申請:端末貸与', '申請:経費精算', '申請:権限追加', '申請:在宅申請'];
// 申請タイトルをランダムに選びます
$title = $titles[array_rand($titles)];
// HTML(li)を1行で作ります(SSEのdataは改行に注意)
$html = '<li class="SSE-ITEM"><strong>承認依頼</strong> #' . $approvalId . ' ' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '(' . htmlspecialchars($user, ENT_QUOTES, 'UTF-8') . ')<span class="SSE-TIME">' . $now . '</span></li>';
// イベント名を指定します
echo "event: approval\n";
// データ本体を送ります(1行)
echo "data: " . $html . "\n\n";
// すぐに送信します
@flush();
}
// ② ジョブ完了
if ($type === 2) {
// ジョブIDを進めます
$jobId++;
// ジョブ名候補を用意します
$jobs = ['夜間集計', 'CSV取り込み', 'バックアップ', '全文検索インデックス更新'];
// ジョブ名をランダムに選びます
$job = $jobs[array_rand($jobs)];
// 所要秒数を作ります
$sec = random_int(2, 12);
// HTML(li)を1行で作ります
$html = '<li class="SSE-ITEM"><strong>ジョブ完了</strong> #' . $jobId . ' ' . htmlspecialchars($job, ENT_QUOTES, 'UTF-8') . '(' . $sec . 's)<span class="SSE-TIME">' . $now . '</span></li>';
// イベント名を指定します
echo "event: job_done\n";
// データ本体を送ります
echo "data: " . $html . "\n\n";
// すぐに送信します
@flush();
}
// ③ 未読件数
if ($type === 3) {
// 未読件数をランダムに増減させます
$delta = random_int(-2, 3);
// 未読件数を更新します
$unread = max(0, $unread + $delta);
// バッジHTML(outerHTML差し替え用)を作ります
$html = '<span id="DEMO_SSE_UNREAD_BADGE" class="BADGE is-warn" sse-swap="unread_count" hx-swap="outerHTML">未読:' . $unread . '</span>';
// イベント名を指定します
echo "event: unread_count\n";
// データ本体を送ります
echo "data: " . $html . "\n\n";
// すぐに送信します
@flush();
}
// 送信間隔を少しランダムにしてリアルっぽくします
$waitMs = random_int(1200, 2800);
// ミリ秒スリープします
usleep($waitMs * 1000);
}
デモ
「承認依頼が来た / ジョブ完了通知 / 未読件数の更新」
読み込み中…
※通知を許可し、通知をONボタンを押すと、通知が来るようになります
※通知をOFFにするには、通知をOFFボタンを押すか(タイムラグがあります)、別ページに遷移してください
解説
共通:1つのSSE接続で、複数イベントを受け取る
-
SSEは「接続しっぱなし」で通知を受け取る方式なので、一定間隔で取りに行くポーリングよりリアルタイム感が出ます。
特に「通知が来たら即反映」が必要な場面に向いています。 -
差し替えは“画面全体”ではなく、“必要な場所だけ”に絞るのがコツです。
更新範囲が小さいほど、レイアウトの揺れやちらつきが減り、運用も安定します。
① 承認依頼が来た(通知を積む)
-
差し替え方は
hx-swap="afterbegin"にして、新着が上に積まれるUIにしています。
通知やログのように“履歴が増える”表示では、afterbeginが特に相性が良いです。 -
増えすぎ対策として、一定件数を超えたら古い行を削るなどの制御を入れておくと、実務でそのまま使いやすくなります。
(デモではJS側で上限件数を整える想定にしています)
② ジョブ完了通知(必要ならブラウザ通知も)
-
Notificationはユーザー操作(ボタン押下など)で許可を取る必要があります。
そのため、このデモでは「通知ON」ボタンを用意し、許可済みのときだけ通知を出すようにしています。 -
常時通知すると“うるさく”なりがちなので、画面が非表示のときだけ通知にする設計が実務向きです。
(表示中は画面内で分かるため、通知は控えめで十分なことが多いです)
③ 未読件数の更新(バッジを差し替える)
-
バッジは1要素なので、
hx-swap="outerHTML"で要素ごと差し替えするとシンプルです。
サーバは「バッジHTMLそのもの」を返すだけで済み、表示ロジックも保ちやすくなります。 -
未読件数は“頻繁に変わる”可能性があるため、SSEのようにサーバからプッシュできる方式が向いています。
ポーリングよりズレが減り、「いつの間にか未読が変わっている」体験を作れます。
ポイント:SSEは便利ですが、接続数が増えるとサーバ負荷や同時接続数の制約が出やすいです。
本番では「通知対象を絞る」「イベントをまとめる」「必要な画面だけ接続する」など、運用の工夫も合わせて考えるのがコツです。
次に読むオススメレシピ
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール