useEventSource
Subscribe to a Server-Sent Events stream with a four-state connection machine, capped exponential-backoff reconnects, named-event subscriptions and callbacks that never re-open the stream.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-event-source.jsonPrompt
Build a React + TypeScript "useEventSource" hook (React only; wraps the native
EventSource API).
Contract
- `useEventSource(url: string, options?: {
enabled?: boolean // default true
withCredentials?: boolean // default false
onMessage?: (event: SseEvent) => void
events?: Record<string, (event: SseEvent) => void>
onOpen?: () => void
onError?: (error: Error, meta: SseErrorMeta) => void
maxRetries?: number // default 5, Infinity allowed
retryDelay?: number // default 1000
maxRetryDelay?: number // default 30000
backoffFactor?: number // default 2
lastEventIdParam?: string | null // default "lastEventId"
historyLimit?: number // default 0
createEventSource?: (url, init) => EventSourceLike
}): {
status: "connecting" | "open" | "closed" | "error"
lastEvent: SseEvent | null
lastEventId: string
error: Error | null
retryCount: number
history: SseEvent[]
droppedCount: number
reconnect: () => void
close: () => void
}`
- `SseEvent = { event: string; data: string; lastEventId: string }` — `data` is the
raw string, never parsed; `event` is "message" for the default stream, or the
server's `event:` field for a named one.
- `SseErrorMeta = { fatal: boolean; attempt: number; willRetry: boolean; retryIn: number }`.
- `reconnect` and `close` have stable identities; `history` is a stable empty
array while `historyLimit` is 0.
Behavior
- **Four states, and they describe the hook rather than mirroring `readyState`.**
`connecting` covers both "opening" and "waiting out a backoff delay" — the same
thing to a UI, and `retryCount` separates them. `open` means the open event
arrived. `closed` and `error` are terminal: the first is a deliberate `close()`
or `enabled: false`, the second is a spent retry budget. Only `reconnect()`
leaves a terminal state.
- **The connection effect depends on `[url, enabled, withCredentials]` and nothing
else.** Every callback (`onMessage`, `events`, `onOpen`, `onError`,
`createEventSource`) is synced into a ref by a dependency-free effect declared
*before* the connection effect. This is the invariant the whole hook is built
around: consumers pass inline arrow functions, so a callback in the dependency
array tears the stream down and re-opens it on every render — it never
establishes, and every event arriving inside the gap is lost with no error
anywhere. Prove it with a test that re-renders ten times with a fresh
`onMessage` each time and asserts the EventSource constructor ran once.
- **Reconnection is taken over from the browser on purpose.** Native EventSource
retries transport-level drops forever on an interval you cannot set, and does
the opposite for an HTTP-level rejection: per spec it *fails the connection*
(readyState CLOSED, no retry) for any non-200 status, a Content-Type other than
`text/event-stream`, 401/407, or a failed CORS check — and it never exposes the
status code. So on the first `error`, read `readyState` (CLOSED means fatal),
immediately `close()` the native object to stop its own loop, and schedule your
own attempt at `min(retryDelay * backoffFactor^(n-1), maxRetryDelay)`. After
`maxRetries` consecutive failures, stop at `error` and let the caller decide;
a successful `open` resets the counter. Call `onError` once per failure and
*last*, after the timer is armed, so a consumer can cancel the scheduled retry
by calling `close()` from inside it — that is the "do not retry a 404" policy.
- **Owning reconnection means owning `Last-Event-ID`.** The browser only sends
that header when *it* reconnects; a freshly constructed EventSource has an empty
last event id, and EventSource cannot set headers at all. So remember the id of
the last delivered event *together with the URL it belongs to*, and on your own
reconnects put it into a query parameter (`lastEventIdParam`, default
`"lastEventId"`, `null` to disable). Servers that do not know the parameter
ignore it and replay from the start — identical to not sending it — so
defaulting it on is safe; disable it when the URL is signed and the query takes
part in the HMAC.
- **Named events are subscriptions, not a firehose.** `events` keys map to
`addEventListener(name)`, matching native semantics: an `event: progress` frame
fires only the `progress` callback and never `onMessage`, and an event nobody
subscribed to is not delivered at all. Track the *set of names* (a sorted joined
key) in a separate effect that adds and removes listeners on the live connection
incrementally — subscribing to one more name at runtime must not drop the
stream. Reserve "open"/"error" (lifecycle, not data), and let "message" reach
both `onMessage` and `events.message`.
- **Do not buffer by default.** `historyLimit` is 0, so only `lastEvent` is kept:
a stream can carry tens of thousands of frames, and an ever-growing array in
state makes every frame re-render more than the last. With a positive limit,
keep a sliding window and count what fell out in `droppedCount` — truncation is
stated, never silent. Consumers who want the whole transcript accumulate it
themselves inside the callback.
- **Lifecycle.** Create the connection in a `queueMicrotask` scheduled from the
effect body, so nothing sets state synchronously inside an effect and
StrictMode's mount then cleanup then mount opens exactly one connection.
Cleanup removes every listener (including named ones), clears the pending retry
timer and calls `close()`. Reset derived state when the URL changes with a
render-phase state adjustment against a tracked previous value, not a
`setState` inside an effect.
- **Clamp every number.** `maxRetries` floors at 0 and accepts `Infinity`; delays
reject NaN, negatives and `Infinity` (`setTimeout(Infinity)` fires immediately,
so "never" would silently become "instantly") and cap at 2^31-1;
`backoffFactor` floors at 1 so the wait can never shrink; `maxRetryDelay` can
never sit below `retryDelay`.
- **Nothing pauses the stream when the tab goes to the background.** EventSource
keeps consuming bandwidth and battery in a hidden tab. `enabled` is the seam:
pass `enabled: isVisible` to suspend it, and accept that events sent while
suspended are lost unless the server honours the resume id. Budget connections
too: HTTP/1.1 allows only six per origin.
Rendering & styling
- The hook renders nothing. Consumers own the UI: semantic tokens only
(`bg-card`, `text-foreground`, `text-muted-foreground`, `bg-primary`,
`bg-muted`, `text-destructive`, `border`), a status badge per state rather than
one boolean spinner, and `motion-reduce:` variants on any progress bar or
caret animation so the stream stays readable with animation off.
Customization levers
- Parsed payloads — `JSON.parse` `event.data` inside your own callback, or add a
`parse?: (raw: string) => T` option and make `SseEvent<T>` generic; keep the raw
string reachable for frames that are not JSON.
- Jitter — the backoff is deterministic on purpose, because that is what makes it
testable; multiply by `1 - jitter * random()` if a server restart would
otherwise bring every client back on the same millisecond.
- Retry policy per error — `onError` receives `fatal`, `attempt` and `willRetry`:
`close()` on fatal to stop at once, or pass a large `maxRetries` and give up on
your own schedule.
- Transport swap — `createEventSource` accepts anything with `readyState`,
`close()` and add/removeEventListener, so you can drop in a fetch-based SSE
client that sends an `Authorization` header, a polyfill, or a scripted fake for
tests and demos (the preview on this page runs entirely on one).
- Pause policy — combine `enabled` with page visibility, an IntersectionObserver
for "is this panel on screen", or a user-facing pause switch.
- Buffer shape — `historyLimit` gives a sliding window; for a transcript, keep the
accumulation in a reducer keyed by `lastEventId` so a resumed stream can
de-duplicate replayed frames.Concepts
- latest-ref callbacks, URL-only dependencies — the connection effect depends on
[url, enabled, withCredentials], while every callback lives in a ref refreshed after each render. A callback in the dependency array is the defining bug of this hook family: consumers pass inline arrows, so the stream gets closed and re-opened on every render and the events arriving in between vanish with no error anywhere. - Taking over reconnection — the native object is closed on the first error so its uncontrollable retry loop stops, and the hook reconnects on
min(retryDelay * factor^(n-1), maxRetryDelay)against a hard budget. Exhausting the budget is a terminalerrorstate rather than a quieter loop: an endless retry with no exit is how "connecting…" becomes permanent for an offline user, and how every client stampedes a server that just restarted. - Fatal versus dropped —
readyStateat the moment of the error is the only signal EventSource gives.CLOSEDmeans the response itself was rejected (non-200, wrongContent-Type, 401/407, CORS) and the browser would never retry;CONNECTINGmeans the transport dropped and it would have retried on its own. Neither path exposes a status code, someta.fatalis what a consumer branches on. - Resume by id, in the query string — the
Last-Event-IDheader is sent only by the browser's own reconnect, and EventSource cannot set headers, so a hook that owns reconnection has to carry the id itself. It is remembered together with the URL it came from (a changed URL is a different stream) and appended as a query parameter that unaware servers simply ignore. - Subscribed events only — a named event fires its own listener and never
onMessage, and an event nobody subscribed to is not delivered. Listener installation is driven by the set of event names, so passing new callback identities changes nothing, and adding a subscription at runtime adds one listener instead of restarting the stream. - Bounded buffer, stated truncation — only the latest event is kept by default, because a long stream would otherwise make every frame re-render a growing array;
historyLimitturns on a sliding window anddroppedCountreports exactly how much fell out, so the cap is never silent data loss.
useOptimistic
Keyed optimistic updates for plain props and plain event handlers — props always reclaim, sync throws become rejections, per-key de-duplication, out-of-order resolves lose, and failures are readable.
useQueryParam
An SSR-safe hook that keeps one piece of component state in a URL query parameter — typed codecs, defaults kept out of the URL, replace by default, and every instance on the page in sync.