ReactでServer-Sent Eventsの通知を受け取る方法

​​React​EventSource,Node.js,Playwright,Server-Sent Events,TypeScript,useEffect,useEffectEvent,カスタムフック

サーバーからブラウザへ一方向に通知を送るだけなら、Server-Sent Events(SSE)が使えます。React では、ブラウザの EventSource を useEffect の中で作り、後片付けで close() するカスタムフックにまとめると、コンポーネントは useEventSource(url, { events, onEvent }) を呼ぶだけで通知を受け取れます。切れたときのつなぎ直しはブラウザが自分で行うので、フックで書くのは、受け取る処理と状態の表示が中心になります。

筆者は検証環境で、Node.js の http モジュールだけで通知を送るサーバーを作り、React の画面を Playwright で操作して、通知の届き方、サーバーを止めたときの動き、タブを増やしたときの接続の数を確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chrome for Testing 153 を Playwright 1.56.1 で操作しました。サーバーは同じマシンの中で動かし、暗号化なしの HTTP/1.1 でつないでいます。HTTPS、HTTP/2、プロキシやロードバランサーをはさんだ構成、ほかのブラウザでは試していません。

サーバーが返すストリームの形

SSE のサーバーは、Content-Type: text/event-stream の応答を閉じずに開いたままにして、通知ができるたびに書き足します。通知は フィールド名: 値 の行を並べたもので、空行で区切ります。検証に使ったサーバーの、通知を返す部分です。

js
if (path === '/events') {
  const entry = { path, lastEventId: req.headers['last-event-id'] ?? null, closed: false };
  log.push(entry);
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
  });
  res.write('retry: 1000\n\n'); // 切れたら1秒後につなぎ直してもらう
  res.write('data: ようこそ\n\n'); // event: のない通知(イベント名は message)
  // つなぎ直しのときは、受け取れなかった通知を送り直す
  const last = Number(entry.lastEventId ?? 0);
  for (const n of notices) if (n.id > last) res.write(format(n));
  clients.add(res);
  req.on('close', () => {
    entry.closed = true;
    clients.delete(res);
  });
  return;
}

新しい通知は、つながっている応答(clients)のすべてに、次の形で書き足します。

js
function format(n) {
  return `event: notice\nid: ${n.id}\ndata: ${JSON.stringify({ text: n.text })}\n\n`;
}

このサーバーに curl でつなぎ、通知を2件作ったときに届いた中身です。curl は1.5秒で打ち切っています。

text
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Date: Sat, 24 Oct 2026 00:51:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

retry: 1000

data: ようこそ

event: notice
id: 1
data: {"text":"ビルドが終わりました"}

event: notice
id: 2
data: {"text":"改行を\n含む通知"}

使ったフィールドは4つです。MDN の、イベントストリームの形式の説明によると、event はイベントの名前、data は中身、id は最後に受け取った ID としてブラウザが覚えておく値、retry は切れたときにつなぎ直すまでの待ち時間(ミリ秒)です。2件目の通知は本文に改行を含みますが、JSON.stringify で \n に置き換わるので、data: の1行で送れました。

通知を受け取るフックの全体

検証に使ったフックです。受け取るイベントの名前(events)と、届いたときの処理(onEvent)を受け取り、接続の状態(status)を返します。

ts
import { useEffect, useEffectEvent, useState } from 'react';

export type EventSourceStatus = 'connecting' | 'open' | 'closed';

export type EventSourceOptions = {
  events?: string[]; // 受け取るイベント名。event: のない通知は 'message'
  onEvent?: (event: MessageEvent<string>) => void; // 通知が届いたときの処理。event.type でイベント名が分かる
  withCredentials?: boolean; // 別のオリジンに Cookie を付けて送るか
};

export function useEventSource(
  url: string,
  { events = ['message'], onEvent, withCredentials = false }: EventSourceOptions = {},
) {
  const [status, setStatus] = useState<EventSourceStatus>('connecting');

  // つなぎ直さずに、届いた時点の最新の onEvent を呼ぶ
  const handleEvent = useEffectEvent((event: MessageEvent<string>) => {
    onEvent?.(event);
  });

  // 配列は描画のたびに別物になるので、中身を文字列にして依存配列に入れる
  const eventKey = events.join(',');

  useEffect(() => {
    setStatus('connecting');
    const source = new EventSource(url, { withCredentials });
    const listener = (event: MessageEvent<string>) => handleEvent(event);
    const names = eventKey.split(',');
    for (const name of names) source.addEventListener(name, listener);

    source.onopen = () => setStatus('open');
    source.onerror = () => {
      // CONNECTING ならブラウザがつなぎ直している。CLOSED ならもうつなぎ直さない
      setStatus(source.readyState === EventSource.CLOSED ? 'closed' : 'connecting');
    };

    return () => {
      for (const name of names) source.removeEventListener(name, listener);
      source.close();
    };
  }, [url, eventKey, withCredentials]);

  return { status };
}

使う側は、受け取るイベントの名前と、届いたときの処理を渡します。検証に使った画面から抜き出したものです。

tsx
const { status } = useEventSource(url, {
  events: ['notice'],
  onEvent: (event) => {
    const { text } = JSON.parse(event.data) as { text: string };
    setNotices((list) => [...list, { id: event.lastEventId, text }]);
  },
});

使う側は描画のたびに新しい onEvent と配列を渡します。そこで onEvent は useEffectEvent で包んで依存配列から外し、events は中身をつないだ文字列にして依存配列に入れています。こうしておくと、描画し直しても接続は作り直されません。外から渡された処理を useEffectEvent で包む書き方は、公式ドキュメントの、カスタムフックにイベントハンドラを渡す例と同じです。

画面を開いてからサーバーで通知を2件作った結果です。

text
つないだあとの状態: 接続済み
画面の通知: ["#1 ビルドが終わりました","#2 改行を\n含む通知"]

通知は ID の順に画面に並び、2件目は元の改行を含む文字列に戻りました。event.lastEventId には、サーバーが id: で送った値が入っています。

EventSource() コンストラクターの MDN のページによると、2つ目の引数で指定できるのは withCredentials だけです。そのため、fetch のように Authorization などのヘッダーを付ける手段はありません。別のオリジンに認証の情報を送るときは、withCredentials を true にして Cookie を付けることになります。この点は、検証環境では試していません。

event: を付けた通知は addEventListener で受け取る

onmessage に処理を入れるだけでは、event: を付けた通知は受け取れません。比べるために、onmessage だけで受け取るコンポーネントを作りました。

tsx
useEffect(() => {
  const source = new EventSource(url);
  source.onmessage = (event) => setReceived((list) => [...list, event.data]);
  return () => source.close();
}, [url]);

同じサーバーにつなぎ、名前付き(event: notice)の通知を2件作った結果です。

text
onmessage で受け取ったもの: ["ようこそ"]
サーバーが送った名前付きの通知: 2 件

届いたのは、event: のない「ようこそ」だけでした。MDN の、event フィールドの説明にあるとおり、名前の付いたイベントは addEventListener() で、その名前を指定して受け取ります。onmessage が呼ばれるのは、名前のない通知だけです。逆に、検証の画面では使う側で events: ['notice'] を指定しているので、フックは「ようこそ」を受け取りません。両方を受け取りたいときは events: ['message', 'notice'] のように並べ、onEvent の中で event.type を見て分けます。

切れても、ブラウザが自分でつなぎ直す

EventSource は、つながっていた接続が切れると、ブラウザが自分でつなぎ直します。以前の記事「ReactでWebSocketの接続をカスタムフックにまとめる方法」では、切れたときのつなぎ直しをフックに自分で書きましたが、EventSource ではその処理は要りません。

つないで通知を2件受け取ったあとにサーバーを止め、止まっているあいだにサーバーの中で通知を2件作り、3.5秒たってから起動し直しました。

text
止める前の通知: ["#1 1件目","#2 2件目"]
止めたあと (0.0秒) の状態: 接続中
止めてから 3.5秒 の状態: 接続中
サーバーを起動し直した (3.5秒)
つながった (4.3秒) の状態: 接続済み
つなぎ直したあとの通知: ["#1 1件目","#2 2件目","#3 3件目(止まっているあいだ)","#4 4件目(止まっているあいだ)"]
サーバーが受けた接続と Last-Event-ID: [null,"2"]
ページで作った EventSource の数: 1

ページで作った EventSource は最初の1つだけで、それが起動し直したサーバーにつながりました。その EventSource で起きた open と error を、作ってからの時間とあわせて記録したものです。

text
  open readyState=1 17ms
  error readyState=0 62ms
  error readyState=0 1063ms
  error readyState=0 2065ms
  error readyState=0 3067ms
  open readyState=1 4071ms

サーバーを止めると error が届き、readyState は 0(CONNECTING)になりました。そのあと、約1秒ごとにつなぎにいっては失敗しています。サーバーが最初に送った retry: 1000 と同じ間隔です。retry: を送らなかったときの間隔は、検証環境では試していません。フックは error のときに readyState を見て、0 なら「接続中」と表示します。

つなぎ直しのリクエストには、Last-Event-ID: 2 というヘッダーが付いていました。最後に受け取った通知の ID です。HTML の仕様の Last-Event-ID ヘッダーの節でも、つなぎ直すときに、覚えている ID をこのヘッダーでサーバーに伝えると決められています。検証のサーバーはこの値より大きい ID の通知を送り直すので、止まっているあいだに作った3件目と4件目も、取りこぼさずに画面に届きました。

次の GIF は、同じことを画面で見せたものです。上の帯の「通知を送る」「止める」などのボタンは、GIF を撮るために足した検証用の操作で、押すと Node.js 側のサーバーで通知を作ったり、サーバーを止めたりします。React の画面は、帯の下の「状態」と通知の一覧です。

上の帯に「操作 1回目/4回」などの表示が出る。通知を送ると画面に #1 が届く。サーバーを止めると状態が「接続中」になり、止まったまま通知を2件作っても画面には届かない。サーバーを起動すると「接続済み」に戻り、#2 と #3 がまとめて届く
通知の受信、サーバーの停止、止まっているあいだの通知、起動したあとに取りこぼした通知が届く動き

送り直しは、サーバーが Last-Event-ID を読んで自分で行う処理です。ブラウザがするのは ID を伝えるところまでなので、通知に id: を付け、サーバーで過去の通知をたどれるようにしておく必要があります。

つなぎ直しを止めさせたいときは、204 を返す

ブラウザがつなぎ直さなくなるのは、応答が SSE のストリームではなかったときです。204 No Content を返すサーバー、application/json を返すサーバー、500 を返すサーバーにつなぎ、4秒待って、サーバーが受けた接続の数と、EventSource に届いたイベントを数えました。

text
/events-204: 状態「停止」、サーバーが受けた接続 1、EventSource のイベント ["error readyState=2"]
/events-json: 状態「停止」、サーバーが受けた接続 1、EventSource のイベント ["error readyState=2"]
/events-500: 状態「停止」、サーバーが受けた接続 1、EventSource のイベント ["error readyState=2"]

どれも error が1回だけ届いて readyState が 2(CLOSED)になり、サーバーへのリクエストは1回だけでした。application/json のときは、コンソールに「EventSource's response has a MIME type ("application/json") that is not "text/event-stream". Aborting the connection.」と出ました。

HTML の仕様の EventSource の節では、ステータスが 200 でないときや、Content-Type が text/event-stream でないときは接続を失敗として終える、と決められています。仕様の導入の節には、204 No Content でつなぎ直しを止めさせられる、とも書かれています。たとえばログアウトしたあとなど、サーバーから「もう来ないでほしい」と伝えたいときは 204 を返します。

フックは、この状態では「停止」と表示して、それ以上は何もしません。サーバーが一時的に 500 を返しただけでもつなぎ直さなくなるので、そのあとまたつなぎたい場合は、画面にボタンを出すなどして、フックに渡す URL を変えるか、コンポーネントを付け直します。

コンポーネントを外したら close する

close() を呼ばないかぎり、接続が切れても EventSource はつなぎ直しを続けます。フックの後片付けで close() を呼んでいるので、コンポーネントを外すと接続が閉じます。

text
外す前: サーバーでつながっている応答 1
外して0.5秒後: サーバーでつながっている応答 0、接続の記録 [{"path":"/events","lastEventId":null,"closed":true}]

サーバーの側では、ブラウザが接続を閉じると req に close が届きます。検証のサーバーは、そこでつながっている応答の一覧から外しているので、閉じた接続に書き込み続けることはありません。

HTTP/1.1 では、同じオリジンへの接続は6本まで

SSE の接続は、通知を待つあいだずっと開いたままです。HTTP/1.1 では、ブラウザが同じオリジンに同時に開く接続の数に上限があるので、タブを増やすと足りなくなります。同じブラウザで、記事の画面を開いたタブを1つずつ7つまで増やしました。

text
サーバーでつながっている応答: 6
タブ1: 接続済み
タブ2: 接続済み
タブ3: 接続済み
タブ4: 接続済み
タブ5: 接続済み
タブ6: 接続済み
タブ7: (画面が表示されていない)
タブ1 から fetch('/ping'): AbortError(3秒待っても返らない)
タブ2 を閉じた
サーバーでつながっている応答: 6
タブ7: 接続済み

6つ目までのタブはつながりましたが、7つ目のタブは画面の HTML さえ読み込めませんでした。つながっているタブ1から同じオリジンに fetch しても、3秒たっても返りません。タブ2を閉じて接続が1本空くと、7つ目のタブが読み込まれてつながりました。

注意

HTTP/1.1 で SSE を使うと、同じオリジンのタブを6つ開いた時点で、7つ目のタブや、つながっているタブからの fetch が待たされます。通知だけでなく、ページの読み込みや API の呼び出しも止まります。

MDN の EventSource のページでも、HTTP/2 を使わない場合、接続の数はブラウザとドメインの組ごとに6までで、タブをまたいで数えられる、と注意されています。同じページによると、HTTP/2 では同時に流せるストリームの数をサーバーとブラウザが取り決め、既定は100です。HTTP/2 での動きは、検証環境では試していません。

SSE を React で使うときのポイント

ポイント

EventSource は useEffect の中で作り、後片付けで close() します。event: を付けた通知は addEventListener で受け取り、受け取る処理は useEffectEvent で包みます。つなぎ直しはブラウザに任せ、通知に id: を付けておけば、サーバーは Last-Event-ID を見て取りこぼした通知を送り直せます。つなぎ直しを止めさせたいときは、サーバーが 204 を返します。HTTP/1.1 では、同じオリジンへの接続がタブをまたいで6本までなので、タブを多く開く使い方では、この上限に気をつけます。

​Posted by 86