2026年8月12日
React useEventSource Hook:自帶斷線重連的 Server-Sent Events (2026)
即時通知、部署日誌、股票行情、一個 token 一個 token 蹦出來的 AI 回答——這些都不需要 WebSocket。它們全是單向的:伺服器說,用戶端聽。瀏覽器早在 2011 年就內建了專門做這件事的協定——Server-Sent Events (SSE)——跑在普通 HTTP 上,穿透代理和負載平衡毫無壓力,斷線還會自動重連。
SSE 缺的是一個好用的 React 封裝。原生 EventSource API 是命令式的:你要 new 出來、掛監聽器、還得在恰當的時機拆乾淨——經典的 effect 生命週期雷區。@reactuses/core 的 useEventSource 把這一切變成宣告式狀態:data、status、error,元件直接渲染就行。這篇文章涵蓋 hook 的完整 API、原生 EventSource 那套微妙出錯的重連行為,以及 useFetchEventSource——一旦你的串流需要 Authorization 標頭或 POST 請求主體就必須換上的 fetch 版變體,而 2026 年的今天,所有 AI 補全介面都是這個形狀。
快速上手
npm install @reactuses/core
import { useEventSource } from "@reactuses/core";
function DeploymentLog() {
const { data, status } = useEventSource("/api/deploy/stream");
return (
<div>
<span>{status === "CONNECTED" ? "🟢 即時" : "🟡 連線中…"}</span>
<pre>{data}</pre>
</div>
);
}
這就是一個完整的即時更新元件。hook 在掛載時打開連線,每條訊息到達就更新 data,把連線生命週期暴露成 status("CONNECTING" | "CONNECTED" | "DISCONNECTED"),元件卸載時自動關閉串流。沒有 ref,沒有監聽器,沒有會忘寫的清理函式。
SSE 到底是什麼(60 秒版)
Server-Sent Events 本質上就是一個永遠不結束的 HTTP 回應。伺服器回傳 Content-Type: text/event-stream,用純文字寫訊息,空行分隔:
data: {"price": 101.42}
id: 7
event: trade
data: {"symbol": "ACME", "qty": 200}
id: 8
三種欄位值得記住:
data:—— 訊息載荷(永遠是字串;結構化資料自己 JSON 編碼)。event:—— 可選的事件名,一條串流可以承載多個頻道。id:—— 可選的事件 ID。瀏覽器會記住最後一個,重連時透過Last-Event-ID請求標頭帶回去,寫得好的伺服器端可以從斷點續傳。
因為是純 HTTP,SSE 穿企業代理、CDN、HTTP/2 多工都不會遇到 WebSocket 偶爾碰上的升級交握鬧劇。代價是:只能伺服器 → 用戶端單向,而且原生瀏覽器 API 只能發不帶自訂請求標頭的 GET。記住這個限制——本文第二個 hook 就是為它而生的。
手寫的方式——以及它咬人的地方
手動接 EventSource 看起來不難:
// ⚠️ 手寫版——三個 bug 蓄勢待發
function Ticker() {
const [price, setPrice] = useState<string | null>(null);
useEffect(() => {
const es = new EventSource("/api/prices");
es.onmessage = e => setPrice(e.data);
return () => es.close();
}, []);
return <span>{price}</span>;
}
問題都在正式環境暴露,demo 裡看不見:
- 重連是無限且不可見的。 伺服器斷開連線後,
EventSource會永遠靜默重試。如果你的 API 掛了,每個打開的分頁都會每隔幾秒錘它一次,直到宇宙熱寂——而且你沒有任何狀態告訴 UI「我們斷線了」,沒法跳橫幅、也沒法認輸。 - 錯誤是不透明的。
onerror只給你一個裸Event——沒有狀態碼,沒有原因。不自己維護連線狀態的話,UI 會把過期資料當即時資料繼續開心地展示。 - 命名事件要手動記帳。 每一條
event: trade都需要自己的addEventListener("trade", …)和清理時配對的removeEventListener。漏一個,在 React 18 StrictMode 的掛載-卸載-掛載循環裡就是監聽器洩漏。
這些都不算難,只是很容易做到 90% 對——而那是最糟糕的一種錯。
useEventSource 的 API
手寫版做不好的一切,都變成回傳的狀態:
const { data, event, status, error, lastEventId, open, close, eventSourceRef } =
useEventSource(url, events?, options?);
data: string | null—— 最新一條訊息的載荷。event: string | null—— 最後收到的命名事件的名字(見下文)。status——"CONNECTING" | "CONNECTED" | "DISCONNECTED"。直接渲染,這就是你的即時指示燈。error: Event | null—— 最近的連線錯誤,重連成功後清空。lastEventId: string | null—— 最後一條訊息的id:欄位,也就是你的續傳游標。open()/close()—— 手動控制。close()是顯式的:它同時停用自動重連,所以「使用者點了暫停」就真的保持暫停。open()重新連線並重設重試計數。eventSourceRef—— 需要摸原生EventSource實例時的逃生艙。
宣告式的命名事件
把關心的事件名作為第二個參數傳入,hook 替你註冊——並清理——所有監聽器:
const { data, event } = useEventSource("/api/stream", ["trade", "quote"]);
// event === "trade" | "quote" | null —— data 來自哪個頻道
useEffect(() => {
if (event === "trade") appendTrade(JSON.parse(data!));
}, [data, event]);
有預算的自動重連
autoReconnect 選項把 EventSource 的靜默無限重試換成你自己選的策略:
const { status } = useEventSource("/api/notifications", [], {
autoReconnect: {
retries: 5, // 重試 5 次就放棄(也可以傳 () => boolean)
delay: 2000, // 每次間隔 2 秒
onFailed: () => toast.error("即時更新不可用——重新整理再試"),
},
});
retries 預設 -1(永遠重試,和原生行為一致),但現在它是一個決策而不是一個驚喜,onFailed 給了你告知使用者的時機。搭配 status === "DISCONNECTED" 渲染降級 UI,而不是靜默展示過期數字。
懶連線
預設掛載即連線。傳 immediate: false 等使用者主動觸發:
const { status, open, close } = useEventSource("/api/live-scores", [], {
immediate: false,
});
<button onClick={status === "CONNECTED" ? close : open}>
{status === "CONNECTED" ? "暫停即時比分" : "開始直播"}
</button>
每篇 SSE 教學都會撞的牆:鑑權請求標頭
原生 API 的髒祕密來了:new EventSource(url) 不能發自訂請求標頭。沒有 Authorization: Bearer …,沒有 X-Api-Key,什麼都沒有。原生 API 下你只有兩條路:cookie(withCredentials: true)或把 token 塞進查詢字串——前者在現代 cookie 政策下跨域基本沒戲,後者會讓你的 token 出現在瀏覽器到伺服器之間的每一份存取日誌裡。
它也不能 POST。這很要命,因為 2026 年最大的 SSE 消費方——OpenAI 風格的 AI 補全介面——全是 POST /v1/chat/completions 加 JSON 請求主體加 bearer token,再以 text/event-stream 串流回傳。原生 EventSource API 根本呼叫不了它們。
useFetchEventSource 用 fetch 來說 SSE(基於微軟久經沙場的 fetch-event-source 解析器),整個請求都由你塑形:
import { useFetchEventSource } from "@reactuses/core";
const { data, status, error } = useFetchEventSource("/api/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ model: "gpt-5", messages, stream: true }),
autoReconnect: { retries: 3, delay: 1000 },
});
回傳形狀和 useEventSource 完全一致——data、event、status、error、lastEventId、open、close——兩者切換是改一行的事,不是重寫。
一個 token 一個 token 地串流渲染 AI 回答
onMessage 回呼是累積串流補全的天然位置:
function Answer({ prompt }: { prompt: string }) {
const [text, setText] = useState("");
const { status } = useFetchEventSource("/api/ask", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ prompt }),
onMessage: msg => {
if (msg.data === "[DONE]") return;
const delta = JSON.parse(msg.data).choices[0]?.delta?.content ?? "";
setText(prev => prev + delta);
},
onError: err => {
if (isRateLimit(err)) return 5000; // 回傳數字 = N 毫秒後重試
},
});
return <Markdown>{text}{status === "CONNECTED" && "▌"}</Markdown>;
}
兩個值得偷走的細節:在 onError 裡回傳數字可以覆蓋這一次的重連延遲(正好用來做 Retry-After 式退避);函式式更新 setText(prev => …) 保證 token 順序在 React 批次處理下不亂。
原生還是 fetch 版——怎麼選?
useEventSource | useFetchEventSource | |
|---|---|---|
| 傳輸層 | 原生 EventSource | fetch + 串流解析器 |
| 自訂請求標頭 / bearer 鑑權 | ❌ | ✅ |
| 帶請求主體的 POST | ❌ | ✅ |
自動 Last-Event-ID 續傳 | ✅ 內建 | 交給你的伺服器端 |
| 額外套件體積 | 零 | 小型解析器相依 |
| 什麼時候用 | 同源或 cookie 鑑權的串流 | AI 介面、token 鑑權、請求主體 |
簡單法則:先用 useEventSource;哪天你敲出 Authorization 這個詞,就換。
正式環境筆記
- SSR 已處理好。 兩個 hook 都只在 effect 裡碰
EventSource/fetch,伺服器端渲染毫髮無傷——你的程式碼裡不用寫typeof window守衛。首屏是status: "DISCONNECTED",然後用戶端接上。 - 背景分頁要暫停。 一個被丟在背景的儀表板分頁會一直佔著串流(以及你伺服器的連線預算)。搭配
useDocumentVisibility,隱藏時close()、回來時open()——Last-Event-ID交握讓續傳很便宜。 - 一個分頁拉串流,其餘收聽。 HTTP/1.1 下瀏覽器對每個來源的並發連線有上限(約 6 個),每個開著 SSE 的分頁都燒掉一個。經典解法:一個分頁持有串流,用
useBroadcastChannel把訊息廣播出去。(或者上 HTTP/2,串流會多工。) - 別往死網路裡重連。
useNetwork或更小巧的useOnline能告訴你瀏覽器已離線——用它閘控你的重試 UI,別在筆電過隧道的時候燒光重試預算。
SSE 不是正確工具的時候
- 用戶端要在同一條通道上回話 ——你要發訊息的聊天、多人游標、協作編輯。那是雙向的,用 WebSocket。
- 更新很稀疏。 一小時變幾次的值不配一條常駐連線——輪詢它,或者用你的資料請求函式庫在視窗聚焦時重新拉取。
- 只交付一次資料。 資料到了回應就結束的,那就是
fetch。只有當串流比請求活得久,SSE 才配得上它的開銷。 - 二進位資料。 SSE 是 UTF-8 文字。二進位走 WebSocket 或分塊
fetch,別 base64 一遍塞進文字串流。
重點回顧
- SSE 是最簡單的即時傳輸:一條長命 HTTP 回應、瀏覽器原生支援、
Last-Event-ID自動續傳——所有伺服器到用戶端的推送都適合它。 useEventSource把命令式的EventSource生命週期變成可渲染的狀態(data/status/error),代管命名事件監聽器的清理,用你設定的重連策略——retries、delay、onFailed——取代不可見的無限重試。- 原生
EventSource發不了Authorization標頭,也發不了 POST 請求主體。useFetchEventSource可以——API 形狀不變,傳輸層換 fetch——這就是串流 AI 補全需要的那塊拼圖。 close()的意思是保持關閉(不再自動重連);open()重設重試預算。把它們接上可見性和網路狀態,你的串流就是個好公民。
useEventSource、useFetchEventSource 和其餘 110+ 個 SSR 安全、TypeScript 優先的 hooks 都在 @reactuses/core 裡——一次安裝,可搖樹,沒有需要你伺候的相依。
npm install @reactuses/core