React EventSource Hooks & State Permalink to this section

Part of Frontend Consumption & Client Patterns.

Raw EventSource usage inside React components creates a predictable class of bugs: connections opened in useEffect without proper cleanup accumulate across re-renders, StrictMode double-invocation fires the connection twice, and uncontrolled state updates trigger avalanche re-renders during high-frequency streams. The solution is a thin hook layer that owns the EventSource lifecycle, coalesces updates, and exposes a stable API surface to consuming components. This guide covers the mechanism, full hook implementation, multi-event subscription patterns, concurrent-mode considerations, and production edge cases.

How EventSource Works Inside React Permalink to this section

EventSource is a browser API defined in the WHATWG HTML spec. It opens a persistent HTTP GET connection to a text/event-stream endpoint and fires DOM events (message, named events, open, error) as the server sends framed data blocks. React has no built-in awareness of this β€” you are responsible for wiring the lifecycle to component mount/unmount.

The spec mandates that a browser automatically reconnects after a network error, by default after 3 seconds (overridable via the server’s retry: field). This means a naive implementation that calls new EventSource(url) in every render body will accumulate zombie connections rather than reuse the existing one.

Key lifecycle facts relevant to hook design:

Event When fired Action needed in hook
open Connection established Set status β†’ "open"
message Server sends data: without event: Dispatch to state
error (recoverable) Network blip, browser will retry Set status β†’ "reconnecting"
error (terminal) readyState === CLOSED, no retry Set status β†’ "error"
close() called Manual teardown Remove listeners, set status β†’ "closed"

The readyState property mirrors this: 0 = CONNECTING, 1 = OPEN, 2 = CLOSED.

React’s useEffect cleanup function is the correct integration point. When the hook’s dependency array changes (e.g. the URL or event type), the cleanup closes the old connection before the new one opens. StrictMode in React 18 calls effects twice in development to surface missing cleanups β€” a properly written hook survives this with exactly one live connection.

React EventSource hook lifecycle and state flow Diagram showing how a useEventSource hook bridges the browser EventSource API to React component state through useEffect, dispatch, and cleanup. React Component useEventSource(url, eventTypes) { data, status } useEventSource Hook useEffect(() => { new EventSource(url) on('message', dispatch) on('error', setStatus) cleanup: es.close() removeEventListener deps: [url, eventTypes] useReducer { data, status, lastId } single dispatch per event SSE Server HTTP/1.1 200 OK Content-Type: text/event-stream id: 42 event: update data: {""{...}""} [blank line] retry: 3000 on error: browser reconnects w/ Last- Event-ID header url GET /stream events dispatch state unmount/ dep change
useEventSource hook bridges browser EventSource lifecycle to React state via useEffect cleanup and useReducer dispatch.

Core Hook Implementation Permalink to this section

A production-grade useEventSource hook needs four things: stable connection identity, coalesced state via useReducer, typed named-event subscriptions, and guaranteed cleanup.

// hooks/useEventSource.ts
import { useEffect, useReducer, useRef, useCallback } from 'react';

export type SSEStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'error' | 'closed';

interface SSEState<T> {
  data: T | null;
  status: SSEStatus;
  lastEventId: string | null;
  error: Event | null;
}

type SSEAction<T> =
  | { type: 'OPEN' }
  | { type: 'MESSAGE'; payload: T; id: string | null }
  | { type: 'ERROR'; payload: Event; readyState: number }
  | { type: 'CLOSE' };

function sseReducer<T>(state: SSEState<T>, action: SSEAction<T>): SSEState<T> {
  switch (action.type) {
    case 'OPEN':
      return { ...state, status: 'open', error: null };
    case 'MESSAGE':
      return { ...state, data: action.payload, lastEventId: action.id };
    case 'ERROR':
      // readyState 2 = CLOSED (terminal); 0 = CONNECTING (browser auto-retrying)
      return {
        ...state,
        status: action.readyState === EventSource.CLOSED ? 'error' : 'reconnecting',
        error: action.payload,
      };
    case 'CLOSE':
      return { ...state, status: 'closed' };
    default:
      return state;
  }
}

export interface UseEventSourceOptions {
  /** Named event types to subscribe to in addition to the default 'message'. */
  eventTypes?: string[];
  /** Pass { withCredentials: true } for cookie-auth streams. */
  withCredentials?: boolean;
  /** Transform raw MessageEvent.data before storing. Default: JSON.parse. */
  deserialize?: (raw: string) => unknown;
  /** Whether to open the connection immediately (default true). */
  enabled?: boolean;
}

export function useEventSource<T = unknown>(
  url: string,
  options: UseEventSourceOptions = {}
): SSEState<T> & { close: () => void } {
  const {
    eventTypes = [],
    withCredentials = false,
    deserialize = JSON.parse,
    enabled = true,
  } = options;

  // Serialize options that affect the connection so the dep array stays stable
  const eventTypesKey = eventTypes.join(',');

  const [state, dispatch] = useReducer(sseReducer as typeof sseReducer<T>, {
    data: null,
    status: 'idle',
    lastEventId: null,
    error: null,
  });

  // Keep an imperative handle for the manual close() escape hatch
  const esRef = useRef<EventSource | null>(null);

  const close = useCallback(() => {
    esRef.current?.close();
    esRef.current = null;
    dispatch({ type: 'CLOSE' });
  }, []);

  useEffect(() => {
    if (!enabled || !url) return;

    dispatch({ type: 'OPEN' }); // optimistically set 'connecting' via OPEN path below

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

    // Set connecting status before the socket is actually open
    // (the reducer maps OPEN action β†’ status:'open' on the actual open event)

    const handleOpen = () => dispatch({ type: 'OPEN' });

    const handleMessage = (ev: MessageEvent) => {
      try {
        const parsed = deserialize(ev.data) as T;
        dispatch({ type: 'MESSAGE', payload: parsed, id: ev.lastEventId || null });
      } catch {
        // malformed JSON β€” surface as error without closing
        console.warn('[useEventSource] deserialize failed', ev.data);
      }
    };

    const handleError = (ev: Event) => {
      dispatch({ type: 'ERROR', payload: ev, readyState: es.readyState });
    };

    es.addEventListener('open', handleOpen);
    es.addEventListener('message', handleMessage);
    es.addEventListener('error', handleError);

    // Subscribe to named event types (e.g. 'update', 'ping', 'alert')
    const namedHandlers: Array<[string, (ev: MessageEvent) => void]> = eventTypes.map(
      (type) => {
        const handler = (ev: MessageEvent) => {
          try {
            const parsed = deserialize(ev.data) as T;
            dispatch({ type: 'MESSAGE', payload: parsed, id: ev.lastEventId || null });
          } catch {
            console.warn(`[useEventSource] deserialize failed for event type: ${type}`, ev.data);
          }
        };
        es.addEventListener(type, handler as EventListener);
        return [type, handler];
      }
    );

    return () => {
      // Cleanup: remove listeners before closing to prevent stale dispatch calls
      es.removeEventListener('open', handleOpen);
      es.removeEventListener('message', handleMessage);
      es.removeEventListener('error', handleError);
      namedHandlers.forEach(([type, handler]) =>
        es.removeEventListener(type, handler as EventListener)
      );
      es.close();
      esRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [url, withCredentials, eventTypesKey, deserialize, enabled]);

  return { ...state, close };
}

The deserialize option defaults to JSON.parse but accepts any transform β€” useful when the server emits plain text, newline-delimited JSON, or base64-encoded blobs. Pass a stable function reference (declared outside the component or wrapped in useCallback) to prevent the effect re-firing on every render.

Consuming the Hook in Components Permalink to this section

A typical usage: a live order-book feed that receives named trade and quote events from the SSE endpoint described in Node.js Streaming Architecture Basics.

// components/OrderBook.tsx
import { useEventSource } from '../hooks/useEventSource';

interface TradeEvent {
  symbol: string;
  price: number;
  qty: number;
  ts: number;
}

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

  const { data, status, lastEventId, close } = useEventSource<TradeEvent>(url, {
    eventTypes: ['trade', 'quote'],
    withCredentials: true,          // send session cookie to authenticated endpoint
  });

  // Connection status badge
  const statusColor: Record<string, string> = {
    open: 'green', reconnecting: 'orange', error: 'red', closed: 'gray', idle: 'gray',
  };

  return (
    <div>
      <span style={{ color: statusColor[status] ?? 'gray' }}>● {status}</span>
      {lastEventId && <small> (last id: {lastEventId})</small>}
      {data && (
        <dl>
          <dt>Symbol</dt><dd>{data.symbol}</dd>
          <dt>Price</dt><dd>{data.price}</dd>
          <dt>Qty</dt><dd>{data.qty}</dd>
        </dl>
      )}
      <button onClick={close}>Disconnect</button>
    </div>
  );
}

When symbol changes, React re-renders, the effect dependency url changes, cleanup closes the old stream, and a new EventSource opens to the new URL β€” without any manual management in the component.

Accumulating Events Into an Array Permalink to this section

The hook above keeps only the latest event in data. Many use cases β€” chat, audit logs, live feeds β€” need a sliding window. Extend the reducer to maintain an array:

// hooks/useEventSourceList.ts
import { useEffect, useReducer } from 'react';

interface ListState<T> {
  events: T[];
  status: string;
}

type ListAction<T> = { type: 'APPEND'; payload: T } | { type: 'SET_STATUS'; status: string };

function listReducer<T>(
  state: ListState<T>,
  action: ListAction<T>,
  maxItems = 200
): ListState<T> {
  if (action.type === 'APPEND') {
    const next = [...state.events, action.payload];
    // Trim oldest entries to cap memory; see performance section below
    return { ...state, events: next.length > maxItems ? next.slice(-maxItems) : next };
  }
  if (action.type === 'SET_STATUS') return { ...state, status: action.status };
  return state;
}

export function useEventSourceList<T>(url: string, maxItems = 200) {
  const reducer = (s: ListState<T>, a: ListAction<T>) => listReducer(s, a, maxItems);
  const [state, dispatch] = useReducer(reducer, { events: [], status: 'idle' });

  useEffect(() => {
    const es = new EventSource(url);
    dispatch({ type: 'SET_STATUS', status: 'connecting' });
    es.onopen = () => dispatch({ type: 'SET_STATUS', status: 'open' });
    es.onmessage = (ev) => dispatch({ type: 'APPEND', payload: JSON.parse(ev.data) });
    es.onerror = () =>
      dispatch({
        type: 'SET_STATUS',
        status: es.readyState === EventSource.CLOSED ? 'error' : 'reconnecting',
      });
    return () => es.close();
  }, [url]);

  return state;
}

Cap maxItems at a sane value (100–500 depending on payload size) to prevent unbounded growth. For state-management integration with Redux or Zustand, pass a stable dispatch from the store into the hook instead of local useReducer.

Re-Render Control & Concurrent Mode Permalink to this section

Every dispatch call schedules a re-render. At 50 events/second, naive useState calls cause 50 re-renders/second β€” expensive if the component tree is large. Strategies:

Throttled dispatch with startTransition β€” React 18’s startTransition marks state updates as non-urgent, allowing the renderer to batch and skip intermediate states under load:

import { startTransition, useTransition } from 'react';

// Inside the effect handler:
const handleMessage = (ev: MessageEvent) => {
  const parsed = JSON.parse(ev.data);
  startTransition(() => {
    dispatch({ type: 'MESSAGE', payload: parsed, id: ev.lastEventId || null });
  });
};

Use startTransition only when the UI can tolerate slight visual lag (dashboards, feeds). Do not use it for critical alerts or UI input confirmations.

Time-based batching β€” accumulate events in a ref-held buffer, flush on an interval:

const bufferRef = useRef<T[]>([]);

const handleMessage = (ev: MessageEvent) => {
  bufferRef.current.push(JSON.parse(ev.data));
};

useEffect(() => {
  const id = setInterval(() => {
    if (bufferRef.current.length === 0) return;
    const batch = bufferRef.current.splice(0);
    dispatch({ type: 'BATCH', payload: batch });
  }, 100); // flush at 10 Hz regardless of event rate
  return () => clearInterval(id);
}, []);

React Suspense β€” EventSource does not integrate with Suspense’s promise-based model out of the box. If you need Suspense-compatible data fetching for the initial load, fetch the snapshot via fetch and use Suspense, then switch to SSE for incremental updates. Do not attempt to throw a promise from an event listener β€” the timing is non-deterministic.

Edge Cases & Network Interference Permalink to this section

Proxy buffering and CDN interference are the most common causes of SSE streams silently failing in production. The browser-side hook cannot fix server misconfiguration, but it can detect and surface the failure.

Problem Symptom in hook Root cause Mitigation
Nginx proxy buffering status: 'connecting' forever Nginx buffers the response until it fills proxy_buffering off + X-Accel-Buffering: no header
CDN caching the stream Single stale event delivered once CDN treats text/event-stream as cacheable Set Cache-Control: no-store on stream endpoint
Firewall 30s idle timeout Periodic error β†’ immediate reconnect Firewall kills idle TCP connections Send SSE comment pings every 15s: : ping\n\n
Load balancer session affinity Events from wrong shard Stateful fan-out requires sticky sessions or pub/sub Use Redis pub/sub fan-out instead
HTTP/1.1 connection limit (6/origin) Max 6 SSE tabs then blocked Browser per-origin connection cap Use HTTP/2 (multiplexed) or consolidate via SharedWorker
CORS preflight fails status: 'error' immediately on cross-origin Missing Access-Control-Allow-Origin Configure CORS on stream route; see Handling CORS in SSE

HTTP/1.1 six-connection limit β€” browsers cap connections per origin at 6 under HTTP/1.1. Each open tab with an SSE connection consumes one slot. Mitigate with a SharedWorker that owns a single EventSource and broadcasts via postMessage to all tabs. HTTP/2 removes the per-origin limit (streams are multiplexed over one TCP connection), making EventSource over HTTP/2 scalable to many tabs.

StrictMode double-invocation β€” React 18 StrictMode in development intentionally mounts, unmounts, then remounts components to surface missing cleanups. The hook handles this correctly: cleanup closes the first connection, the second mount opens a fresh one. Verify in DevTools Network tab that you see at most one active EventStream after the double-invocation settles.

withCredentials and third-party cookies β€” when the SSE endpoint is on a different subdomain, withCredentials: true sends cookies but requires Access-Control-Allow-Credentials: true and a specific Access-Control-Allow-Origin (not *). See Authenticating SSE Streams with Tokens & Cookies for the full token-based alternative.

Performance & Scale Considerations Permalink to this section

Memory β€” each EventSource object is lightweight (a few KB of browser state), but the React component state holding event data is not. For high-frequency streams (>10 events/s), cap list size with the maxItems pattern above. Beware of accumulating parsed objects that retain closures over large outer scopes.

CPU β€” JSON.parse on the main thread at high event rates degrades interactive performance. For bursts >100 events/s, move deserialization to a Web Worker and post parsed objects back, or use a faster parser like @ungap/json. The hook’s deserialize option is the right extension point.

Re-render cost β€” profile with React DevTools Profiler. If a high-frequency SSE feed is re-rendering the entire page, lift the useEventSource hook into a context provider with useMemo/useCallback barriers, or use a ref-based subscription pattern (like useSyncExternalStore) to let components opt into only the slices they need:

import { useSyncExternalStore } from 'react';

// Create a standalone SSE store outside React
function createSSEStore<T>(url: string) {
  let current: T | null = null;
  const listeners = new Set<() => void>();
  const es = new EventSource(url);
  es.onmessage = (ev) => {
    current = JSON.parse(ev.data);
    listeners.forEach((l) => l());
  };
  return {
    subscribe: (cb: () => void) => { listeners.add(cb); return () => listeners.delete(cb); },
    getSnapshot: () => current,
    destroy: () => es.close(),
  };
}

// Component uses only the data slice it needs, re-renders only on change
const store = createSSEStore<{ price: number }>('/api/ticker');
function PriceDisplay() {
  const data = useSyncExternalStore(store.subscribe, store.getSnapshot);
  return <span>{data?.price}</span>;
}

useSyncExternalStore is React 18’s canonical API for subscribing to external data sources β€” it avoids tearing in concurrent rendering.

Connection count at scale β€” a fleet of 10,000 connected clients with one EventSource each is 10,000 open HTTP connections to your server. Plan file descriptor limits (see Tuning File-Descriptor Limits for SSE Connection Pools), use SO_REUSEPORT to distribute across cores, and ensure your reverse proxy (nginx/Caddy) passes Connection: keep-alive through to the origin. For mobile and background-tab scenarios, close the EventSource when the Page Visibility API signals hidden to free server-side resources.

Validation & Debugging Permalink to this section

Verify the raw stream with curl β€” before debugging the React layer, confirm the endpoint emits valid text/event-stream:

# Stream 10 events then exit; --no-buffer prevents curl's own buffering
curl -N --no-buffer \
  -H "Accept: text/event-stream" \
  -H "Cookie: session=<your-cookie>" \
  https://your-api.example.com/api/stream/orderbook?symbol=BTC

Expected output (one event block):

id: 42
event: trade
data: {"symbol":"BTC","price":67241.50,"qty":0.01,"ts":1750512000000}

Each field on its own line, terminated by a blank line. Missing blank line = browser will buffer the event until the next one arrives.

Chrome DevTools β€” EventStream tab:

  1. Open DevTools β†’ Network β†’ filter by EventStream (or Fetch/XHR)
  2. Click the SSE request
  3. Open the EventStream tab: see each event with id, type, data, and timestamp
  4. If the tab is empty but the request shows β€œpending”, proxy buffering is active

React DevTools Profiler β€” record a session while the SSE stream is active. Look for components that render more than once per event. If App re-renders on every message, the useEventSource hook is placed too high in the tree or lacks memoization.

Structured logging in the hook:

// Add to the hook for development debugging; strip in production builds
if (process.env.NODE_ENV === 'development') {
  es.onopen = () => console.debug('[SSE] open', url);
  es.onerror = (e) => console.debug('[SSE] error', url, es.readyState, e);
}

Reconnection verification β€” to test the browser reconnection path without restarting the server, use the Chrome DevTools Network conditions panel to simulate offline, then bring the network back. The hook should transition: open β†’ reconnecting β†’ open, and the browser should send Last-Event-ID in the reconnect request header, which the server uses to replay missed events per the event ID & retry mechanism.

Production Checklist Permalink to this section

⚑ Production Directives

  • Always remove event listeners before calling es.close() in the cleanup function β€” stale listeners on a closed EventSource can dispatch into unmounted component state and trigger React warnings.
  • Gate EventSource creation behind an enabled flag; open the connection only after authentication is confirmed to avoid unauthenticated stream requests on page load.
  • Use useSyncExternalStore or a context barrier to prevent a high-frequency SSE feed from causing full-tree re-renders β€” profile before shipping to production.
  • Enable HTTP/2 on your origin; without it, browsers enforce a 6-connection-per-origin limit that blocks SSE in multi-tab apps.
  • Validate the stream endpoint with curl -N --no-buffer before debugging the React layer β€” most "hook not receiving events" issues are server-side buffering or missing blank-line terminators.

Frequently Asked Questions Permalink to this section

Why does my hook open two connections in development?

React 18 StrictMode intentionally mounts, unmounts, and remounts components in development to detect missing cleanups. If your useEffect cleanup correctly calls es.close() and removes event listeners, the second mount opens a fresh connection and the first is closed. You will briefly see two requests in the Network tab, but only one will remain open. This is expected behaviour β€” it does not happen in production builds.

Can I use EventSource with React Suspense for the initial data load?

EventSource is not compatible with Suspense's promise-throwing model because it delivers data asynchronously via event listeners, not via a promise that resolves once. The recommended pattern is: fetch the initial snapshot via a standard fetch call wrapped in a Suspense-compatible library (React Query, SWR), then switch to SSE for incremental updates. Do not attempt to throw a promise from an onmessage handler.

How do I subscribe to multiple named event types?

Pass an array to the eventTypes option: useEventSource(url, { eventTypes: ['trade', 'quote', 'alert'] }). The hook calls es.addEventListener(type, handler) for each type and removes them all in the cleanup. The default message listener (for events without an event: field) is always active regardless of this option. If you need different state shapes per event type, maintain a Record<string, T> in the reducer keyed by event type.

How do I pause the stream when the browser tab is hidden?

Combine the enabled option with the Page Visibility API: const [visible, setVisible] = useState(!document.hidden), then wire a visibilitychange listener to update visible, and pass enabled={visible} to the hook. When enabled becomes false, the hook's cleanup fires and closes the connection, freeing the server-side slot. See the full pattern in Mobile & Background-Tab Handling.

What is the correct way to pass auth tokens to an EventSource?

EventSource does not support custom request headers β€” it is a browser-managed GET. Your options are: (1) include the token as a URL query parameter (/stream?token=…) and validate server-side β€” acceptable for short-lived tokens; (2) use cookie-based auth with withCredentials: true β€” the browser sends session cookies automatically; (3) use fetch with ReadableStream instead of EventSource, which supports arbitrary headers. See Authenticating SSE Streams with Tokens & Cookies for a full comparison.

Deep Dives