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は便利ですが、接続数が増えるとサーバ負荷や同時接続数の制約が出やすいです。
本番では「通知対象を絞る」「イベントをまとめる」「必要な画面だけ接続する」など、運用の工夫も合わせて考えるのがコツです。

次に読むオススメレシピ

このページの著者

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

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

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

得意:PHP・JavaScript・MySQL・CSS

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

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

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

もちもちみかん.comとは


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

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