{"slug": "react-useeventsource-hook-server-sent-events-with-auto-reconnect-2026", "title": "React useEventSource Hook: Server-Sent Events with Auto-Reconnect (2026)", "summary": "The React UseEventSource hook from the @reactuses/core library provides a declarative interface for Server-Sent Events with automatic reconnection, addressing the limitations of the native EventSource API. The hook exposes data, status, and error as state, simplifying live-updating components for use cases like deployment logs and AI streaming. It also includes useFetchEventSource for requests with custom headers or POST bodies, which is essential for AI completions endpoints.", "body_md": "Live notifications, deployment logs, stock tickers, AI responses streaming in token by token — none of these need a WebSocket. They're all one-directional: the server talks, the client listens. The browser has had a native protocol for exactly this since 2011 — **Server-Sent Events (SSE)** — and it runs over plain HTTP, passes through proxies and load balancers untouched, and reconnects automatically when the connection drops.\n\nWhat SSE *doesn't* have is a good React story. The native `EventSource`\n\nAPI is imperative: you construct it, attach listeners, and must tear it down at exactly the right moment — the classic effect-lifecycle minefield. [ useEventSource](https://reactuse.com/browser/useeventsource/) from\n\n`@reactuses/core`\n\n`data`\n\n, `status`\n\n, and `error`\n\nyour component just renders. This post covers the hook's full API, the reconnect behavior that native `EventSource`\n\ngets subtly wrong, and `useFetchEventSource`\n\n`Authorization`\n\nheader or a POST body, which in 2026 means every AI-completions endpoint.\n\n```\nnpm install @reactuses/core\njs\nimport { useEventSource } from \"@reactuses/core\";\n\nfunction DeploymentLog() {\n  const { data, status } = useEventSource(\"/api/deploy/stream\");\n\n  return (\n    <div>\n      <span>{status === \"CONNECTED\" ? \"🟢 live\" : \"🟡 connecting…\"}</span>\n      <pre>{data}</pre>\n    </div>\n  );\n}\n```\n\nThat's a complete live-updating component. The hook opens the connection on mount, updates `data`\n\non every message, exposes the connection lifecycle as `status`\n\n(`\"CONNECTING\" | \"CONNECTED\" | \"DISCONNECTED\"`\n\n), and closes the stream when the component unmounts. No refs, no listeners, no cleanup function to forget.\n\nServer-Sent Events is just an HTTP response that never finishes. The server replies with `Content-Type: text/event-stream`\n\nand writes messages as plain text, separated by blank lines:\n\n```\ndata: {\"price\": 101.42}\nid: 7\n\nevent: trade\ndata: {\"symbol\": \"ACME\", \"qty\": 200}\nid: 8\n```\n\nThree field types matter:\n\n`data:`\n\n— the payload (always a string; JSON-encode structured data yourself).`event:`\n\n— an optional event `id:`\n\n— an optional event ID. The browser remembers the last one and sends it back as a `Last-Event-ID`\n\nheader when it reconnects, so a well-built server can resume where the client left off.Because it's plain HTTP, SSE works through corporate proxies, CDNs, and HTTP/2 multiplexing without the upgrade-handshake drama WebSockets sometimes hit. The trade-off: it's server → client only, and the native browser API can only send GET requests with no custom headers. Keep that limitation in mind — it's the reason the second hook in this post exists.\n\nWiring `EventSource`\n\nby hand looks manageable:\n\n```\n// ⚠️ hand-rolled — three bugs waiting to happen\nfunction Ticker() {\n  const [price, setPrice] = useState<string | null>(null);\n\n  useEffect(() => {\n    const es = new EventSource(\"/api/prices\");\n    es.onmessage = e => setPrice(e.data);\n    return () => es.close();\n  }, []);\n\n  return <span>{price}</span>;\n}\n```\n\nThe problems show up in production, not in the demo:\n\n`EventSource`\n\nretries forever, silently. If your API is down, every open tab hammers it every few seconds until the heat death of the universe — and you have no state telling the UI \"we're offline\" so you can show a banner or give up.`onerror`\n\ngives you a bare `Event`\n\n— no status code, no reason. If you don't track connection state yourself, your UI happily shows stale data as if it were live.`event: trade`\n\nline requires its own `addEventListener(\"trade\", …)`\n\n`removeEventListener`\n\nin cleanup. Miss one and you leak listeners across React 18 StrictMode's mount-unmount-mount cycle.None of this is hard, exactly. It's just easy to get 90% right — which is the worst kind of wrong.\n\nEverything the manual version does badly, as returned state:\n\n```\nconst { data, event, status, error, lastEventId, open, close, eventSourceRef } =\n  useEventSource(url, events?, options?);\n```\n\n`data: string | null`\n\n`event: string | null`\n\n`status`\n\n`\"CONNECTING\" | \"CONNECTED\" | \"DISCONNECTED\"`\n\n. Render it; that's your live-indicator.`error: Event | null`\n\n`lastEventId: string | null`\n\n`id:`\n\nfield of the last message, i.e. your resume cursor.`open()`\n\n/ `close()`\n\n`close()`\n\nis `open()`\n\nreconnects and resets the retry counter.`eventSourceRef`\n\n`EventSource`\n\ninstance if you need it.Pass the event names you care about as the second argument, and the hook registers — and cleans up — every listener for you:\n\n```\nconst { data, event } = useEventSource(\"/api/stream\", [\"trade\", \"quote\"]);\n\n// event === \"trade\" | \"quote\" | null — which channel data came from\nuseEffect(() => {\n  if (event === \"trade\") appendTrade(JSON.parse(data!));\n}, [data, event]);\n```\n\nThe `autoReconnect`\n\noption replaces `EventSource`\n\n's silent infinite retry with a policy you choose:\n\n```\nconst { status } = useEventSource(\"/api/notifications\", [], {\n  autoReconnect: {\n    retries: 5,        // give up after 5 attempts (or pass a () => boolean)\n    delay: 2000,       // wait 2s between attempts\n    onFailed: () => toast.error(\"Live updates unavailable — refresh to retry\"),\n  },\n});\n```\n\n`retries`\n\ndefaults to `-1`\n\n(retry forever, matching native behavior), but now it's a *decision* rather than a surprise, and `onFailed`\n\ngives you the moment to tell the user. Pair it with `status === \"DISCONNECTED\"`\n\nto render a degraded-mode UI instead of silently stale numbers.\n\nBy default the hook connects on mount. Pass `immediate: false`\n\nto wait for user intent:\n\n```\nconst { status, open, close } = useEventSource(\"/api/live-scores\", [], {\n  immediate: false,\n});\n\n<button onClick={status === \"CONNECTED\" ? close : open}>\n  {status === \"CONNECTED\" ? \"Pause live scores\" : \"Go live\"}\n</button>\n```\n\nHere is the native API's dirty secret: `new EventSource(url)`\n\n**cannot send custom headers**. No `Authorization: Bearer …`\n\n, no `X-Api-Key`\n\n, nothing. Your options with the native API are cookies (`withCredentials: true`\n\n) or a token in the query string — one of which doesn't work cross-domain with modern cookie policies, and the other of which lands your token in every access log between the browser and your server.\n\nIt also can't POST. That matters because the biggest SSE consumers of 2026 — OpenAI-style AI completion endpoints — are all `POST /v1/chat/completions`\n\nwith a JSON body and a bearer token, streaming back `text/event-stream`\n\n. The native `EventSource`\n\nAPI literally cannot call them.\n\n[ useFetchEventSource](https://reactuse.com/browser/usefetcheventsource/) solves this by speaking SSE over\n\n`fetch`\n\n(built on Microsoft's battle-tested `fetch-event-source`\n\n``` js\nimport { useFetchEventSource } from \"@reactuses/core\";\n\nconst { data, status, error } = useFetchEventSource(\"/api/v1/chat/completions\", {\n  method: \"POST\",\n  headers: { Authorization: `Bearer ${token}` },\n  body: JSON.stringify({ model: \"gpt-5\", messages, stream: true }),\n  autoReconnect: { retries: 3, delay: 1000 },\n});\n```\n\nSame return shape as `useEventSource`\n\n— `data`\n\n, `event`\n\n, `status`\n\n, `error`\n\n, `lastEventId`\n\n, `open`\n\n, `close`\n\n— so switching between the two is a one-line change, not a rewrite.\n\nThe `onMessage`\n\ncallback is the natural place to accumulate a streamed completion:\n\n```\nfunction Answer({ prompt }: { prompt: string }) {\n  const [text, setText] = useState(\"\");\n\n  const { status } = useFetchEventSource(\"/api/ask\", {\n    method: \"POST\",\n    headers: { Authorization: `Bearer ${token}` },\n    body: JSON.stringify({ prompt }),\n    onMessage: msg => {\n      if (msg.data === \"[DONE]\") return;\n      const delta = JSON.parse(msg.data).choices[0]?.delta?.content ?? \"\";\n      setText(prev => prev + delta);\n    },\n    onError: err => {\n      if (isRateLimit(err)) return 5000; // return a number = retry after N ms\n    },\n  });\n\n  return <Markdown>{text}{status === \"CONNECTED\" && \"▌\"}</Markdown>;\n}\n```\n\nTwo details worth stealing: returning a number from `onError`\n\noverrides the reconnect delay for that attempt (perfect for `Retry-After`\n\n-style backoff), and the functional `setText(prev => …)`\n\nupdate means token order survives React's batching.\n\n`useEventSource` |\n|---|\n\n`useFetchEventSource`\n\n`EventSource`\n\n`fetch`\n\n+ stream parser`Last-Event-ID`\n\nresumeSimple rule: start with `useEventSource`\n\n; the moment you type the word `Authorization`\n\n, switch.\n\n`EventSource`\n\n/`fetch`\n\nonly inside effects, so they render harmlessly on the server — no `typeof window`\n\nguards in your code. First paint shows `status: \"DISCONNECTED\"`\n\n, then the client connects.`useDocumentVisibility`\n\n`close()`\n\nwhen hidden and `open()`\n\non return — the `Last-Event-ID`\n\nhandshake makes resume cheap.`useBroadcastChannel`\n\n`useNetwork`\n\n`useOnline`\n\n`fetch`\n\n. SSE earns its keep only when the stream outlives the request.`fetch`\n\ninstead of base64-ing it through a text stream.`Last-Event-ID`\n\n— right for every server-to-client feed.`useEventSource`\n\n`EventSource`\n\nlifecycle into rendered state (`data`\n\n/ `status`\n\n/ `error`\n\n), handles named-event listener cleanup, and replaces invisible infinite retry with a reconnect policy you set — `retries`\n\n, `delay`\n\n, `onFailed`\n\n.`EventSource`\n\ncan't send an `Authorization`\n\nheader or a POST body. `useFetchEventSource`\n\n`close()`\n\nmeans `open()`\n\nresets the retry budget. Wire them to visibility and network state for streams that behave like a good citizen.`useEventSource`\n\n, `useFetchEventSource`\n\n, and 110+ other SSR-safe, TypeScript-first hooks live in [ @reactuses/core](https://reactuse.com) — one install, tree-shakeable, no dependencies to babysit.\n\n```\nnpm install @reactuses/core\n```\n\n", "url": "https://wpnews.pro/news/react-useeventsource-hook-server-sent-events-with-auto-reconnect-2026", "canonical_source": "https://dev.to/childrentime/react-useeventsource-hook-server-sent-events-with-auto-reconnect-2026-fbm", "published_at": "2026-08-12 07:06:05+00:00", "updated_at": "2026-08-12 07:16:17.034113+00:00", "lang": "en", "topics": ["developer-tools", "ai-products"], "entities": ["React", "@reactuses/core", "useEventSource", "useFetchEventSource", "EventSource", "Server-Sent Events"], "alternates": {"html": "https://wpnews.pro/news/react-useeventsource-hook-server-sent-events-with-auto-reconnect-2026", "markdown": "https://wpnews.pro/news/react-useeventsource-hook-server-sent-events-with-auto-reconnect-2026.md", "text": "https://wpnews.pro/news/react-useeventsource-hook-server-sent-events-with-auto-reconnect-2026.txt", "jsonld": "https://wpnews.pro/news/react-useeventsource-hook-server-sent-events-with-auto-reconnect-2026.jsonld"}}