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β anEventSourcelistener 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
EventSourcewas never replaced. - TypeScript errors because
EventSourcelacks 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:
-
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
EventSourceon every render without closing the old one, you accumulate live connections each trying to reconnect independently. -
Listener identity.
addEventListenerandremoveEventListeneruse referential equality. An inline arrow function registered inside auseEffectwithoutuseCallbackcreates a new function reference each render, meaningremoveEventListeneron cleanup never matches and the old listener leaks. -
No typed event map. The browser
EventSourceonly types the genericmessageevent. 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
- Open Network β Filter: Fetch/XHR and look for your SSE endpoint. The EventStream tab shows each frame with its
id,event, anddatafields. - 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.
- 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
useMemoif 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
onErrorclosures by storing the callback in a ref inside the hook β do not list it as auseEffectdependency. - Wrap SSE endpoints behind a CDN or edge proxy only if you have confirmed the proxy forwards
Transfer-Encoding: chunkedframes without buffering β see the buffer management guide. - Emit monotonic
id:fields from the server so the browser can replay missed events on reconnect viaLast-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.