Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * The connection state machine. **Four states only**, and they describe what this hook
 * is doing right now — they are not a mirror of the native `readyState`:
 *
 * - `connecting` — opening a connection, **or** waiting out a backoff before the next
 *   retry. To the UI those are the same thing ("nothing is flowing yet"); read
 *   `retryCount` to tell them apart.
 * - `open` — the stream is up, the `open` event arrived.
 * - `closed` — the consumer called `close()`, or `enabled: false`. **Terminal: it never reconnects on its own.**
 * - `error` — where it stops once the retry budget is spent. **Terminal**; only the caller's `reconnect()` gets out.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-event-source.json

Prompt

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 terminal error state 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 droppedreadyState at the moment of the error is the only signal EventSource gives. CLOSED means the response itself was rejected (non-200, wrong Content-Type, 401/407, CORS) and the browser would never retry; CONNECTING means the transport dropped and it would have retried on its own. Neither path exposes a status code, so meta.fatal is what a consumer branches on.
  • Resume by id, in the query string — the Last-Event-ID header 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; historyLimit turns on a sliding window and droppedCount reports exactly how much fell out, so the cap is never silent data loss.

On This Page