Building a useEventSource React Hook Permalink to this section

Part of React EventSource Hooks & State.

Connecting to a Server-Sent Events endpoint inside a React component without a dedicated hook leads to a predictable cluster of bugs: the EventSource object outlives the component, listeners accumulate on re-render, and the stream reconnects silently after a navigation while dispatching state updates to an unmounted tree. The canonical fix is a single, well-typed useEventSource hook that owns the connection lifecycle from mount to unmount, exposes reconnection control, and tracks the latest event data in stable state.

Symptom & Developer Intent Permalink to this section

The immediate symptoms look like this:

  • Warning: Can't perform a React state update on an unmounted component β€” an EventSource listener fires after route navigation.
  • Duplicate events arriving in rapid succession β€” multiple listeners registered because the hook re-ran without closing the previous connection.
  • The stream never reconnects when the token is rotated and the URL changes β€” because the old EventSource was never replaced.
  • TypeScript errors because EventSource lacks typed event maps out of the box.

The developer intent is a hook with this API:

const { data, readyState, error, close } = useEventSource<MyPayload>(url, options);

Where data is the parsed payload of the most recent message event (or a named event), readyState mirrors EventSource.CONNECTING | OPEN | CLOSED, error holds the last error event, and close lets the caller terminate the stream without unmounting.

Root Cause Analysis Permalink to this section

EventSource is a persistent browser object. Unlike fetch, it does not terminate when a component unmounts β€” it stays open, continues to call registered listeners, and triggers React’s stale-closure warnings. Three protocol-level facts amplify this:

  1. Automatic browser reconnection. Per the Event ID & Retry Mechanism Design spec, the browser reconnects after a configurable delay (default 3 s). If you create a new EventSource on every render without closing the old one, you accumulate live connections each trying to reconnect independently.

  2. Listener identity. addEventListener and removeEventListener use referential equality. An inline arrow function registered inside a useEffect without useCallback creates a new function reference each render, meaning removeEventListener on cleanup never matches and the old listener leaks.

  3. No typed event map. The browser EventSource only types the generic message event. Named events (event: stockTick\ndata: ...) require manual casting, making TypeScript’s type-checker silent on payload shape errors. See Understanding the Event Stream Format for how the wire format maps to named events.

The URL dependency is the trickiest: when authentication tokens rotate and the URL changes (e.g., a signed query parameter), useEffect must detect the change, close the old connection, and open a new one β€” while preserving the last-event-id header so the server can replay missed events per the reconnection spec.

Step-by-Step Resolution Permalink to this section

Step 1 β€” Define the hook’s type contracts Permalink to this section

// hooks/useEventSource.ts
export type SSEReadyState =
  | typeof EventSource.CONNECTING  // 0
  | typeof EventSource.OPEN        // 1
  | typeof EventSource.CLOSED;     // 2

export interface UseEventSourceOptions {
  /** Named event type to listen for (default: "message"). */
  eventName?: string;
  /** Pass true to send cookies/auth headers via credentials mode. */
  withCredentials?: boolean;
  /** Called when the browser closes the stream with readyState CLOSED. */
  onError?: (event: Event) => void;
}

export interface UseEventSourceResult<T> {
  data: T | null;
  readyState: SSEReadyState;
  error: Event | null;
  /** Permanently close the connection (will not reconnect). */
  close: () => void;
}

Keeping types in the same file avoids import cycles and makes the hook self-contained.

Step 2 β€” Scaffold the hook with stable refs Permalink to this section

import { useCallback, useEffect, useRef, useState } from "react";
import type {
  UseEventSourceOptions,
  UseEventSourceResult,
  SSEReadyState,
} from "./useEventSource";

export function useEventSource<T = unknown>(
  url: string | null,
  options: UseEventSourceOptions = {}
): UseEventSourceResult<T> {
  const { eventName = "message", withCredentials = false, onError } = options;

  const [data, setData] = useState<T | null>(null);
  const [readyState, setReadyState] = useState<SSEReadyState>(
    EventSource.CONNECTING
  );
  const [error, setError] = useState<Event | null>(null);

  // Ref holds the live EventSource so callbacks never capture a stale instance.
  const esRef = useRef<EventSource | null>(null);
  // Stable ref for caller's onError so it isn't a useEffect dependency.
  const onErrorRef = useRef(onError);
  useEffect(() => { onErrorRef.current = onError; }, [onError]);

  const close = useCallback(() => {
    esRef.current?.close();
    setReadyState(EventSource.CLOSED);
  }, []);

Using a ref for the EventSource instance means handlers always operate on the current object without being listed as useEffect dependencies β€” a pattern that avoids the stale-closure trap.

Step 3 β€” Open, bind, and clean up the connection Permalink to this section

  useEffect(() => {
    // Null URL = intentionally disabled (e.g., user not logged in).
    if (url === null) {
      setReadyState(EventSource.CLOSED);
      return;
    }

    const es = new EventSource(url, { withCredentials });
    esRef.current = es;
    setReadyState(EventSource.CONNECTING);
    setError(null);

    const handleOpen = () => setReadyState(EventSource.OPEN);

    const handleMessage = (evt: MessageEvent) => {
      try {
        // Attempt JSON parse; fall back to raw string for plain-text streams.
        setData(JSON.parse(evt.data) as T);
      } catch {
        setData(evt.data as unknown as T);
      }
    };

    const handleError = (evt: Event) => {
      setError(evt);
      setReadyState(es.readyState as SSEReadyState);
      onErrorRef.current?.(evt);
      // Do NOT close here β€” let the browser retry per the SSE spec.
    };

    es.addEventListener("open", handleOpen);
    es.addEventListener(eventName, handleMessage as EventListener);
    es.addEventListener("error", handleError);

    return () => {
      // Cleanup runs before the next effect or on unmount.
      es.removeEventListener("open", handleOpen);
      es.removeEventListener(eventName, handleMessage as EventListener);
      es.removeEventListener("error", handleError);
      es.close();
      esRef.current = null;
    };
  }, [url, eventName, withCredentials]); // Re-run only when connection params change

  return { data, readyState, error, close };
}

Critical detail: the cleanup function closes es β€” the local variable captured by the closure β€” not esRef.current. By the time cleanup runs, esRef.current may already point to the next EventSource if React batched two rapid renders. Using the closure variable guarantees the correct instance is torn down.

Step 4 β€” Consume the hook in a component Permalink to this section

// components/LiveStockPrice.tsx
import { useEventSource } from "../hooks/useEventSource";

interface StockPayload {
  symbol: string;
  price: number;
  ts: number;
}

export function LiveStockPrice({ symbol }: { symbol: string }) {
  const url = `/api/stocks/stream?symbol=${encodeURIComponent(symbol)}`;

  const { data, readyState, error, close } = useEventSource<StockPayload>(url, {
    eventName: "stockTick",      // matches  event: stockTick  on the wire
    withCredentials: true,       // send session cookie for auth
  });

  if (readyState === EventSource.CONNECTING) return <p>Connecting…</p>;
  if (error) return <p>Stream error β€” retrying automatically.</p>;

  return (
    <div>
      <span>{data?.symbol}: ${data?.price.toFixed(2)}</span>
      <button onClick={close}>Disconnect</button>
    </div>
  );
}

The eventName: "stockTick" option maps directly to event: stockTick lines in the text/event-stream wire format. Without this, the browser only fires message events, silently dropping named events.

Step 5 β€” Add last-event-id replay support Permalink to this section

The browser automatically sends Last-Event-ID on reconnect, but only when the server sets id: fields. To expose the last ID in the hook for debugging or conditional logic:

// Add inside the useEffect, after `const es = new EventSource(...)`:
const handleMessageWithId = (evt: MessageEvent) => {
  // evt.lastEventId is populated when the server sends  id: <value>
  // Store in a ref if the caller needs it for custom replay logic.
  lastEventIdRef.current = evt.lastEventId;
  handleMessage(evt);
};

// Then register handleMessageWithId instead of handleMessage.
es.addEventListener(eventName, handleMessageWithId as EventListener);

The lastEventId property on MessageEvent reflects the most recent id: field received. The browser stores it internally and sends it as the Last-Event-ID request header on the next reconnect β€” no application code required. See Generating Monotonic Event IDs for SSE for server-side ID strategies that make replay reliable.

Validation & Monitoring Permalink to this section

Verify in DevTools Permalink to this section

  1. Open Network β†’ Filter: Fetch/XHR and look for your SSE endpoint. The EventStream tab shows each frame with its id, event, and data fields.
  2. Navigate away from the component and confirm the connection row disappears (status changes from pending to cancelled). If it stays open, the cleanup is not running.
  3. Rotate the URL (e.g., append &v=2) and confirm a new connection row appears while the old one closes β€” not two concurrent open rows.

Unit-test stub (Vitest + jsdom) Permalink to this section

// hooks/useEventSource.test.ts
import { renderHook, act } from "@testing-library/react";
import { useEventSource } from "./useEventSource";

// jsdom ships a stub EventSource; replace with a controllable mock.
class MockEventSource {
  static CONNECTING = 0; static OPEN = 1; static CLOSED = 2;
  readyState = MockEventSource.CONNECTING;
  listeners: Record<string, EventListener[]> = {};
  constructor(public url: string, public init?: EventSourceInit) {}
  addEventListener(type: string, fn: EventListener) {
    (this.listeners[type] ??= []).push(fn);
  }
  removeEventListener(type: string, fn: EventListener) {
    this.listeners[type] = (this.listeners[type] ?? []).filter(f => f !== fn);
  }
  close() { this.readyState = MockEventSource.CLOSED; }
  // Helper: simulate an open event
  open() {
    this.readyState = MockEventSource.OPEN;
    this.listeners["open"]?.forEach(fn => fn(new Event("open")));
  }
  // Helper: simulate a message
  dispatch(type: string, data: string) {
    const evt = Object.assign(new MessageEvent(type, { data }), { lastEventId: "" });
    this.listeners[type]?.forEach(fn => fn(evt as unknown as Event));
  }
}

let lastEs: MockEventSource;
vi.stubGlobal("EventSource", class extends MockEventSource {
  constructor(url: string, init?: EventSourceInit) {
    super(url, init);
    lastEs = this;
  }
});

it("returns parsed data on message event", async () => {
  const { result } = renderHook(() =>
    useEventSource<{ v: number }>("/stream")
  );
  act(() => lastEs.open());
  act(() => lastEs.dispatch("message", JSON.stringify({ v: 42 })));
  expect(result.current.data).toEqual({ v: 42 });
  expect(result.current.readyState).toBe(EventSource.OPEN);
});

it("closes the EventSource on unmount", () => {
  const { unmount } = renderHook(() => useEventSource("/stream"));
  unmount();
  expect(lastEs.readyState).toBe(MockEventSource.CLOSED);
});

cURL smoke test for the SSE endpoint Permalink to this section

curl -N \
  -H "Accept: text/event-stream" \
  -H "Cache-Control: no-cache" \
  https://api.example.com/stocks/stream?symbol=AAPL
# Expected output (one block per tick):
# event: stockTick
# id: 1718900000001
# data: {"symbol":"AAPL","price":189.42,"ts":1718900000001}
#

The -N flag disables curl’s output buffering, so frames print as they arrive. Confirm Content-Type: text/event-stream and Cache-Control: no-cache in the response headers before wiring up the hook. Buffer management and chunked transfer encoding covers the server-side headers that prevent proxy buffering from swallowing your frames.

Verification Checklist Permalink to this section

⚑ Production Directives

  • Always pass a stable URL to the hook β€” derive it with useMemo if it depends on props, so reference equality prevents spurious reconnects.
  • Never set retry: on the server below 1000 ms in production; combined with multiple browser tabs this hammers the endpoint on outages.
  • Guard against memory leaks from onError closures by storing the callback in a ref inside the hook β€” do not list it as a useEffect dependency.
  • Wrap SSE endpoints behind a CDN or edge proxy only if you have confirmed the proxy forwards Transfer-Encoding: chunked frames without buffering β€” see the buffer management guide.
  • Emit monotonic id: fields from the server so the browser can replay missed events on reconnect via Last-Event-ID.

Frequently Asked Questions Permalink to this section

Why does the hook re-open the connection when the component re-renders?

Only changes to url, eventName, or withCredentials trigger a new EventSource. If any of these is an inline expression (e.g., a template literal or object literal computed during render), React sees a new reference each render and the effect re-runs. Memoize the URL with useMemo and pass eventName as a stable string constant to prevent this.

How do I listen to multiple named event types?

Call useEventSource once per event type, or extend the hook to accept an array: eventNames: string[]. Inside the effect, register one listener per name and merge payloads with a discriminated union type. Alternatively, multiplex all server events under a single message type with a type field in the JSON body β€” simpler but loses the native named-event dispatch.

The browser reconnects automatically β€” why do I also need a close() function?

The browser's automatic reconnect fires on error events (network interruption, server restart). close() is for intentional, permanent disconnection β€” e.g., the user clicks "Stop live updates". After close(), readyState becomes CLOSED and the browser will NOT reconnect. To resume, remount the component or pass a new URL to force the effect to re-run.

Does this hook work with the Fetch-based streaming API (fetch + ReadableStream)?

No β€” EventSource and fetch + ReadableStream are distinct APIs. EventSource handles the SSE text/event-stream parsing, reconnect header, and Last-Event-ID for you. fetch + ReadableStream gives you raw bytes and requires a manual parser. The hook in this guide wraps EventSource. Use the fetch approach only when you need POST bodies, custom request headers at connection time, or environments without EventSource support β€” see Cross-Browser Compatibility for the EventSource API for polyfill options.

How do I pass an auth token if I can't use cookies?

Append it as a query parameter: const url = \`/stream?token=${token}\`. Memoize with useMemo([token]) so the hook reconnects when the token rotates. Avoid the Authorization header β€” EventSource does not support custom request headers natively. For a deeper treatment see Authenticating SSE Streams with Tokens & Cookies.