Production-ready React hooks for WebSocket and SSE with auto-reconnect, heartbeat, typed connection state, and browser network awareness including page visibility and connection gating.
react-realtime-hooks is for apps that need more than "open a socket and hope for the best". It gives you composable hooks for transport lifecycle, retry strategy, heartbeat, online status, page visibility, and connection gating, so your UI can react to realtime state without rebuilding the same connection logic in every screen.
Live demo: https://volkov85.github.io/react-realtime-hooks/
Most realtime helpers stop at transport setup.
Real apps need:
- explicit
connecting/reconnecting/closed/errorstates - reconnect strategy with caps, jitter, and manual control
- heartbeat and timeout tracking
- clean SSR behavior
- browser network and page visibility awareness
- environment-aware connection gating for offline state and background tabs
- typed message parsing and sending
react-realtime-hooks packages those concerns into small hooks that compose cleanly in React.
useWebSocketanduseEventSourcereturn state you can render, not just transport instances.- Built-in reconnect flow with exponential backoff, jitter, attempt limits, and manual restart.
- Heartbeat support with ack matching, timeout detection, and latency measurement.
useConnectionGateturns online and visibility signals into a singleconnectflag for transport hooks.- Discriminated connection snapshots:
idle,connecting,open,reconnecting,closing,closed,error. - First-class TypeScript support with generic message types and custom parsers/serializers.
- SSR-safe by default. No browser-only globals are touched during server render.
- Strict Mode safe. Works correctly under React 18+
<StrictMode>and Next.js dev double-mount — see Strict Mode Safety. - Zero runtime dependencies beyond React.
- Manual controls stay available when you need them:
open(),close(),reconnect(),send().
| Concern | Raw WebSocket | react-realtime-hooks |
|---|---|---|
| Connection state | You model it yourself | Built-in status model you can render directly |
| Reconnect flow | Manual timers and teardown | useReconnect with backoff, jitter, and limits |
| Heartbeat | Custom ping/pong loop | heartbeat support with timeout and latency |
| Browser awareness | Separate browser event wiring | useOnlineStatus, usePageVisibility, and useConnectionGate for browser-aware state |
| SSR safety | Easy to break during render | Browser-only behavior stays out of server render |
| UI ergonomics | Event handlers and refs everywhere | Hook result already shaped for product UI |
The point is not to hide WebSocket. The point is to stop rewriting the same lifecycle machinery around it.
npm install react-realtime-hooksPeer dependency:
react@>=18.0.0 <20.0.0(tested against React 18.3, 19.0, and 19.2)
import {
useOnlineStatus,
usePageVisibility,
useWebSocket
} from "react-realtime-hooks";
type IncomingMessage =
| { type: "notification"; text: string }
| { type: "pong" };
type OutgoingMessage = { type: "ack"; id: string } | { type: "ping" };
export function NotificationsPanel() {
const network = useOnlineStatus();
const page = usePageVisibility();
const socket = useWebSocket<IncomingMessage, OutgoingMessage>({
url: "ws://localhost:8080/notifications",
parseMessage: (event) => JSON.parse(String(event.data)) as IncomingMessage,
reconnect: {
initialDelayMs: 1_000,
},
heartbeat: {
intervalMs: 10_000,
timeoutMs: 3_000,
message: { type: "ping" },
matchesAck: (message) => message.type === "pong",
},
});
return (
<section>
<p>
Page: {page.isVisible ? "visible" : "hidden"} | Network:{" "}
{network.isOnline ? "online" : "offline"} | Transport: {socket.status}
</p>
{socket.status === "reconnecting" && (
<p>Retrying in {socket.reconnectState?.nextDelayMs ?? 0}ms</p>
)}
{socket.heartbeatState?.hasTimedOut && <p>Heartbeat timed out</p>}
<button
disabled={socket.status !== "open"}
onClick={() => socket.send({ type: "ack", id: "msg-42" })}
>
Ack latest
</button>
<pre>{JSON.stringify(socket.lastMessage, null, 2)}</pre>
</section>
);
}You are not wiring raw onopen, onclose, and timer cleanup by hand. You render the current transport state and keep moving.
The transport hooks return a discriminated status model, so UI states stay explicit instead of collapsing into a vague isConnected boolean.
idle: auto-connect is off and nothing is openingconnecting: first connection attempt is in progressopen: transport is livereconnecting: retry flow is activeclosing: explicit close is in progressclosed: transport is stopped and will not continueerror: an unrecoverable parse/runtime error occurred
That makes product UI straightforward:
- show a retry banner on
reconnecting - disable send buttons unless
status === "open" - show offline or degraded indicators without guessing
- surface heartbeat timeout separately from transport close
This library is built as layered primitives, not one giant "magic realtime client".
Browser APIs
WebSocket / EventSource / navigator.onLine
Core hooks
useReconnect / useHeartbeat / useOnlineStatus / usePageVisibility / useConnectionGate
Transport hooks
useWebSocket / useEventSource
UI
banners, badges, retry states, feed views, chat inputs
That separation matters:
- you can use
useReconnectanduseHeartbeatoutside the transport hooks - transport hooks stay predictable instead of hiding lifecycle decisions
- the UI gets a stable state model instead of raw event listeners
- Chat and support widgets that need reconnect and delivery-aware UI
- Notification centers and activity feeds over WebSocket
- Live dashboards and ops consoles consuming SSE streams
- Trading, analytics, and monitoring UIs with explicit connection states
- Device and IoT panels that need heartbeat and timeout visibility
- Collaborative tools that must reflect degraded or reconnecting transport state
This package is intentionally not trying to be a full client platform.
- No bundled transport polyfills
- No opinionated server protocol
- No hidden global singleton connection manager
- No built-in auth refresh flow
- No state management framework or cache layer
- No "smart" abstractions that erase transport state details
If you need a predictable hook layer for realtime UI, that is the point. If you need a full messaging platform, this is a lower-level building block.
Because "just a socket hook" turns into more work than it looks like:
- reconnect timers need careful cleanup and manual-close semantics
- heartbeat loops need ack matching, timeout handling, and teardown discipline
- URL changes and remounts create subtle race conditions
- SSR breaks if browser globals leak into render
- a single
isOpenflag is not enough for real UI states - parse failures and transport errors need consistent state transitions
This library already models those edges in a reusable way.
| Hook | Use it for | Returns |
|---|---|---|
useWebSocket |
Bidirectional realtime channels | status, socket, lastMessage, send(), reconnect(), heartbeatState |
useEventSource |
Server-Sent Events streams | status, eventSource, lastMessage, lastEventName, reconnect() |
useReconnect |
Reusable retry and backoff logic | schedule(), cancel(), reset(), attempt, status |
useHeartbeat |
Liveness checks and timeout tracking | start(), stop(), beat(), notifyAck(), latencyMs |
useConnectionGate |
Browser-aware transport gating | connect, reason, isBlocked, gate transition timestamps |
useOnlineStatus |
Browser online/offline state | isOnline, isSupported, transition timestamps |
usePageVisibility |
Browser tab/page visibility state | isVisible, visibilityState, isSupported, transition timestamps |
import { useWebSocket } from "react-realtime-hooks";
type IncomingMessage = {
type: "chat" | "system";
text: string;
};
type OutgoingMessage = {
type: "ping" | "chat";
text?: string;
};
export function ChatSocket() {
const socket = useWebSocket<IncomingMessage, OutgoingMessage>({
url: "ws://localhost:8080",
parseMessage: (event) => JSON.parse(String(event.data)) as IncomingMessage,
reconnect: {
initialDelayMs: 1_000,
},
heartbeat: {
intervalMs: 10_000,
timeoutMs: 3_000,
message: { type: "ping" },
matchesAck: (message) =>
message.type === "system" && message.text === "pong",
},
});
return (
<button onClick={() => socket.send({ type: "chat", text: "Hello" })}>
Send
</button>
);
}The native WebSocket does not emit any event when buffered bytes drain to the network, so by default bufferedAmount only refreshes on send(), on incoming messages, and on the open transition. Pass bufferedAmountPolling to surface the live value for backpressure indicators:
const socket = useWebSocket<IncomingMessage, OutgoingMessage>({
url: "ws://localhost:8080",
// "raf" reads `bufferedAmount` on every animation frame while open;
// use `true` for a 100ms interval, or `{ intervalMs: 50 }` for custom.
bufferedAmountPolling: "raf",
});
// `socket.bufferedAmount` now ticks down as the OS flushes the buffer,
// even between `send` calls. Identical readings do not trigger React
// re-renders.import { useEventSource } from "react-realtime-hooks";
type FeedItem = {
id: string;
level: "info" | "warn";
text: string;
};
export function LiveFeed() {
const feed = useEventSource<FeedItem>({
url: "http://localhost:8080/sse",
events: ["notice"],
parseMessage: (event) => JSON.parse(event.data) as FeedItem,
reconnect: {
initialDelayMs: 1_000,
maxAttempts: 10,
},
});
return (
<div>
{feed.lastEventName}: {feed.lastMessage?.text ?? "Waiting for updates"}
</div>
);
}import { useReconnect } from "react-realtime-hooks";
export function RetryPanel() {
const reconnect = useReconnect({
initialDelayMs: 1_000,
maxAttempts: 5,
jitterRatio: 0,
});
return (
<button onClick={() => reconnect.schedule("manual")}>Retry now</button>
);
}import { useHeartbeat } from "react-realtime-hooks";
export function HeartbeatPanel() {
const heartbeat = useHeartbeat<string, string>({
intervalMs: 5_000,
timeoutMs: 2_000,
startOnMount: true,
matchesAck: (message) => message === "pong",
});
return (
<div>
running: {String(heartbeat.isRunning)} | latency:{" "}
{heartbeat.latencyMs ?? "n/a"}
</div>
);
}import { useOnlineStatus } from "react-realtime-hooks";
export function NetworkIndicator() {
const network = useOnlineStatus({
trackTransitions: true,
});
return <span>{network.isOnline ? "Online" : "Offline"}</span>;
}import { usePageVisibility } from "react-realtime-hooks";
export function AttentionAwareBadge() {
const page = usePageVisibility({
trackTransitions: true,
});
return (
<span>
{page.isVisible ? "Active tab" : "Background tab"} ({page.visibilityState})
</span>
);
}import { useConnectionGate, useWebSocket } from "react-realtime-hooks";
export function GatedNotifications() {
const gate = useConnectionGate({
requireOnline: true,
requireVisible: true,
hiddenGraceMs: 30_000,
});
const socket = useWebSocket({
connect: gate.connect,
reconnect: {
initialDelayMs: 1_000,
maxAttempts: null,
},
// ^ pass `null` explicitly to opt back into unlimited reconnect attempts;
// the default in 2.0 caps retries at 10.
url: "ws://localhost:8080/notifications",
});
return (
<div>
Gate: {gate.reason} | Transport: {socket.status}
</div>
);
}useWebSocket
| Option | Type | Default | Description |
|---|---|---|---|
url |
UrlProvider |
Required | String, URL, or lazy URL factory |
protocols |
string | string[] |
undefined |
WebSocket subprotocols |
connect |
boolean |
true |
Auto-connect on mount |
binaryType |
BinaryType |
"blob" |
Socket binary mode |
bufferedAmountPolling |
false | true | "raf" | { intervalMs: number } |
false |
Live polling of WebSocket.bufferedAmount for backpressure UIs |
parseMessage |
(event) => TIncoming |
raw event.data |
Incoming parser |
serializeMessage |
(message) => ... |
JSON/string passthrough | Outgoing serializer |
reconnect |
false | UseReconnectOptions |
enabled | Reconnect configuration |
heartbeat |
false | UseWebSocketHeartbeatOptions |
disabled unless configured | Heartbeat configuration |
shouldReconnect |
(event) => boolean |
true |
Reconnect gate on close |
onOpen |
(event, socket) => void |
undefined |
Open callback |
onMessage |
(message, event) => void |
undefined |
Message callback |
onError |
(event) => void |
undefined |
Called for transport, heartbeat, and parse errors |
onClose |
(event) => void |
undefined |
Close callback |
| Field | Type | Description |
|---|---|---|
status |
connection union | idle, connecting, open, closing, closed, reconnecting, error |
socket |
WebSocket | null |
Current transport instance |
lastMessage |
TIncoming | null |
Last parsed message |
lastMessageEvent |
MessageEvent | null |
Last raw message event |
lastCloseEvent |
CloseEvent | null |
Last close event |
lastError |
Event | null |
Last transport error, or RealtimeErrorEvent for parse/heartbeat failures |
bufferedAmount |
number |
Current socket buffer size (refresh cadence is controlled by bufferedAmountPolling) |
reconnectState |
reconnect snapshot or null |
Current reconnect data |
heartbeatState |
heartbeat snapshot or null |
Current heartbeat data |
open |
() => void |
Manual connect |
close |
(code?, reason?) => void |
Manual close |
reconnect |
() => void |
Manual reconnect |
send |
(message) => boolean |
Sends an outgoing payload |
When you configure useWebSocket heartbeat, you can also set timeoutAction and
errorAction to "none", "close", or "reconnect". The default is
"reconnect" when reconnect is enabled and "close" otherwise.
When parsing or heartbeat execution fails, onError receives a
RealtimeErrorEvent with kind and cause so the original thrown value is not
lost.
useEventSource
| Option | Type | Default | Description |
|---|---|---|---|
url |
UrlProvider |
Required | String, URL, or lazy URL factory |
withCredentials |
boolean |
false |
Passes credentials to EventSource |
connect |
boolean |
true |
Auto-connect on mount |
events |
readonly string[] |
undefined |
Named SSE events to subscribe to |
parseMessage |
(event) => TMessage |
raw event.data |
Incoming parser |
reconnect |
false | UseReconnectOptions |
enabled | Reconnect configuration |
shouldReconnect |
(event) => boolean |
true |
Reconnect gate on error |
onOpen |
(event, source) => void |
undefined |
Open callback |
onMessage |
(message, event) => void |
undefined |
Default message callback |
onError |
(event) => void |
undefined |
Called for transport and parse errors |
onEvent |
(eventName, message, event) => void |
undefined |
Named event callback |
| Field | Type | Description |
|---|---|---|
status |
connection union | idle, connecting, open, closing, closed, reconnecting, error |
eventSource |
EventSource | null |
Current transport instance |
lastEventName |
string | null |
Last SSE event name |
lastMessage |
TMessage | null |
Last parsed payload |
lastMessageEvent |
MessageEvent | null |
Last raw message event |
lastError |
Event | null |
Last transport error, or RealtimeErrorEvent for parse failures |
reconnectState |
reconnect snapshot or null |
Current reconnect data |
open |
() => void |
Manual connect |
close |
() => void |
Manual close |
reconnect |
() => void |
Manual reconnect |
On EventSource errors, the hook closes the current native source and schedules
the next connection through useReconnect. That makes SSE retries use the
configured backoff, jitter, and attempt limits instead of the browser's built-in
retry loop.
useReconnect
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Enables scheduling attempts |
initialDelayMs |
number |
1000 |
Delay for the first attempt |
maxDelayMs |
number |
30000 |
Delay cap |
backoffFactor |
number |
2 |
Exponential multiplier |
jitterRatio |
number |
0.2 |
Randomized variance ratio |
maxAttempts |
number | null |
10 |
Max attempts before "stopped"; pass null for unlimited |
getDelayMs |
ReconnectDelayStrategy |
undefined |
Custom delay strategy |
resetOnSuccess |
boolean |
true |
Resets attempt count after success |
onSchedule |
(attempt) => void |
undefined |
Called when an attempt is scheduled |
onCancel |
() => void |
undefined |
Called when scheduling is canceled |
onReset |
() => void |
undefined |
Called when state is reset |
| Field | Type | Description |
|---|---|---|
status |
"idle" | "scheduled" | "running" | "stopped" |
Current reconnect state |
attempt |
number |
Current attempt number |
nextDelayMs |
number | null |
Delay of the scheduled attempt |
isActive |
boolean |
true when scheduled or running |
isScheduled |
boolean |
true when waiting for the next attempt |
schedule |
(trigger?) => void |
Schedules an attempt |
cancel |
() => void |
Cancels the current schedule |
reset |
() => void |
Resets attempts and status |
markConnected |
() => void |
Marks the transport as restored |
useHeartbeat
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Enables the heartbeat loop |
intervalMs |
number |
Required | Beat interval |
timeoutMs |
number | null |
10_000 |
Timeout in ms before hasTimedOut becomes true; pass null to disable |
message |
TOutgoing | (() => TOutgoing) |
undefined |
Optional heartbeat payload |
beat |
() => void | boolean | Promise<void | boolean> |
undefined |
Custom beat side effect |
matchesAck |
(message) => boolean |
undefined |
Ack matcher |
startOnMount |
boolean |
true |
Starts immediately |
onBeat |
() => void |
undefined |
Called on every beat |
onTimeout |
() => void |
undefined |
Called on timeout |
onError |
(error) => void |
undefined |
Called when beat() throws or rejects |
| Field | Type | Description |
|---|---|---|
isRunning |
boolean |
Whether the loop is active |
hasTimedOut |
boolean |
Whether the latest beat timed out |
lastBeatAt |
number | null |
Last beat timestamp |
lastAckAt |
number | null |
Last ack timestamp |
latencyMs |
number | null |
Ack latency |
start |
() => void |
Starts the loop |
stop |
() => void |
Stops the loop |
beat |
() => void |
Triggers a manual beat |
notifyAck |
(message) => boolean |
Applies an incoming ack message |
useConnectionGate
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Master on/off switch for the gate |
requireOnline |
boolean |
true |
Blocks connect when the browser reports offline |
requireVisible |
boolean |
false |
Blocks connect when the page is hidden |
hiddenGraceMs |
number |
0 |
Delay before hidden pages are blocked |
initialOnline |
boolean |
true |
Fallback value when navigator.onLine is unavailable |
initialVisible |
boolean |
true |
Fallback value when the Visibility API is unavailable |
trackTransitions |
boolean |
true |
Tracks lastChangedAt, becameReadyAt, and becameBlockedAt |
| Field | Type | Description |
|---|---|---|
connect |
boolean |
Flag to pass into useWebSocket or useEventSource |
isBlocked |
boolean |
Whether the gate is currently blocking connection |
reason |
"ready" | "manual" | "offline" | "hidden" |
Current gate reason |
isWaitingForVisibleGrace |
boolean |
true while a hidden-tab grace window is still active |
isOnline |
boolean |
Current browser online state |
isOnlineSupported |
boolean |
Whether navigator.onLine is available |
isVisible |
boolean |
Whether the current page is visible |
isVisibilitySupported |
boolean |
Whether document.visibilityState is available |
visibilityState |
DocumentVisibilityState | "visible" |
Current browser visibility state |
lastChangedAt |
number | null |
Timestamp of the last gate state change |
becameReadyAt |
number | null |
Timestamp of the last transition into reason === "ready" |
becameBlockedAt |
number | null |
Timestamp of the last transition into a blocked state |
reason priority is deterministic: manual overrides offline, and offline overrides hidden.
That keeps the gate predictable when multiple blockers apply at once.
useOnlineStatus
| Option | Type | Default | Description |
|---|---|---|---|
initialOnline |
boolean |
true |
Fallback value when navigator.onLine is unavailable |
trackTransitions |
boolean |
true |
Tracks lastChangedAt, wentOnlineAt, wentOfflineAt |
| Field | Type | Description |
|---|---|---|
isOnline |
boolean |
Current browser online state |
isSupported |
boolean |
Whether navigator.onLine is available |
lastChangedAt |
number | null |
Timestamp of the last transition |
wentOnlineAt |
number | null |
Timestamp of the last online transition |
wentOfflineAt |
number | null |
Timestamp of the last offline transition |
usePageVisibility
| Option | Type | Default | Description |
|---|---|---|---|
initialVisible |
boolean |
true |
Fallback value when the Visibility API is unavailable |
trackTransitions |
boolean |
true |
Tracks lastChangedAt, becameVisibleAt, becameHiddenAt |
| Field | Type | Description |
|---|---|---|
isVisible |
boolean |
Whether the current page is visible |
visibilityState |
DocumentVisibilityState | "visible" |
Current browser visibility state |
isSupported |
boolean |
Whether document.visibilityState is available |
lastChangedAt |
number | null |
Timestamp of the last visibility transition |
becameVisibleAt |
number | null |
Timestamp of the last visible transition |
becameHiddenAt |
number | null |
Timestamp of the last hidden transition |
useEventSourceis receive-only by design. SSE is not a bidirectional transport.useWebSocketheartbeat support is client-side. You still define your own server ping/pong protocol.- If
parseMessagethrows, the hook callsonErrorwithRealtimeErrorEvent, closes the current transport, moves intoerror, storeslastError, and stops auto-reconnect until manualopen()orreconnect(). - Stopping heartbeat clears timeout state and the previous beat/ack timestamps so a new session starts with fresh metrics.
connect: falsekeeps the hook inidleuntilopen()is called.- Manual
close()is sticky. The hook stays closed untilopen()orreconnect()is called. - No transport polyfills are bundled. Provide your own runtime support where needed.
- Browser-native transport constraints still apply: auth, proxy, CORS, and network policy are outside the hook's control.
All hooks are safe to use under React 18+ <React.StrictMode> and the Next.js App Router's dev mode, both of which intentionally mount → unmount → mount each component on first render to surface effect-cleanup bugs.
Concretely:
useWebSocketanduseEventSourcedefer the actualnew WebSocket(...)/new EventSource(...)call by a microtask. If the component unmounts before that microtask runs (the Strict Mode discard mount), no transport is ever created. If the mount survives, exactly one transport is created — never two.- All effects in the library tear down their timers, listeners, and transports synchronously in the cleanup function. There is no "zombie"
setInterval, no orphanedaddEventListener, no leakedWebSocketleft inCONNECTINGafter a discarded mount. - Latest-state refs are committed via
useInsertionEffect, not by writing toref.currentduring render. A discarded render never leaves the ref out of sync with the committed tree. - The library's own test suite runs every hook test inside
<React.StrictMode>by default, so any regression that only shows up under double-mount is caught in CI.
If you observe a Strict Mode regression — e.g. two simultaneous WebSocket connections, an EventSource that survives a closed component, or a heartbeat that keeps firing after unmount — please open an issue with a minimal repro. That class of bug is supposed to be impossible by construction, and we treat it as a correctness defect.
The package includes behavior tests for:
- connect / disconnect / reconnect
- exponential backoff
- timer and listener cleanup
- heartbeat start / stop / timeout
- browser offline / online and page visibility transitions
- invalid payload and parse errors
- manual reconnect and manual close
WebSocket and EventSource are tested through mocked browser APIs.
- Live demo: https://volkov85.github.io/react-realtime-hooks/
- Repository: https://github.com/volkov85/react-realtime-hooks
Run the local playground:
npm run demoTwo defaults change in 2.0 (reconnect.maxAttempts → 10,
heartbeat.timeoutMs → 10_000). See MIGRATING.md for the
exact diff and how to restore 1.x behaviour.
Development and release workflow live in CONTRIBUTING.md.
MIT