react-realtime-hooks 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,474 +1,547 @@
1
- # react-realtime-hooks
2
-
3
- Typed React hooks for realtime client state: WebSocket, SSE, reconnect, heartbeat, and online status.
4
-
5
- `react-realtime-hooks` is built for apps that need transport state, retry strategy, and browser network signals without rewriting the same connection lifecycle in every component.
6
-
7
- Live demo: https://volkov85.github.io/react-realtime-hooks/
8
-
9
- ## Why This Package
10
-
11
- - Realtime hooks usually stop at "open a socket". This package also models reconnect flow, heartbeat flow, browser online state, and transport snapshots.
12
- - TypeScript support is first-class: generic payload types, custom parsers/serializers, and discriminated connection states.
13
- - It is SSR-safe by default. The hooks avoid touching browser-only globals during server render.
14
- - The package has no runtime dependencies beyond React.
15
- - The repo is set up like a library product, not just a demo: tests, CI, publint, Changesets, and a demo app.
16
-
17
- ## Features
18
-
19
- - `useWebSocket`
20
- - `useEventSource`
21
- - `useReconnect`
22
- - `useHeartbeat`
23
- - `useOnlineStatus`
24
- - Type-safe connection snapshots
25
- - Reconnect/backoff helpers
26
- - Browser API mocks in tests
27
- - Demo app for manual verification
28
- - Public GitHub Pages playground
29
-
30
- ## Install
31
-
32
- ```bash
33
- npm install react-realtime-hooks
34
- ```
35
-
36
- Peer dependency:
37
-
38
- - `react@^18.3.0 || ^19.0.0`
39
-
40
- ## Quick Start
41
-
42
- ```tsx
43
- import { useWebSocket } from "react-realtime-hooks";
44
-
45
- export const Notifications = () => {
46
- const socket = useWebSocket<{ type: string; message: string }>({
47
- url: "ws://localhost:8080",
48
- reconnect: {
49
- initialDelayMs: 1_000,
50
- maxAttempts: 5
51
- }
52
- });
53
-
54
- if (socket.status === "open") {
55
- return <div>{socket.lastMessage?.message ?? "Connected"}</div>;
56
- }
57
-
58
- return <div>{socket.status}</div>;
59
- };
60
- ```
61
-
62
- ## Links
63
-
64
- - Demo: https://volkov85.github.io/react-realtime-hooks/
65
- - Repository: https://github.com/volkov85/react-realtime-hooks
66
-
67
- ## Examples
68
-
69
- ### useOnlineStatus
70
-
71
- ```tsx
72
- import { useOnlineStatus } from "react-realtime-hooks";
73
-
74
- export const NetworkIndicator = () => {
75
- const network = useOnlineStatus({
76
- trackTransitions: true
77
- });
78
-
79
- return (
80
- <span>
81
- {network.isOnline ? "Online" : "Offline"}
82
- </span>
83
- );
84
- };
85
- ```
86
-
87
- ### useReconnect
88
-
89
- ```tsx
90
- import { useReconnect } from "react-realtime-hooks";
91
-
92
- export const RetryPanel = () => {
93
- const reconnect = useReconnect({
94
- initialDelayMs: 1_000,
95
- maxAttempts: 5,
96
- jitterRatio: 0
97
- });
98
-
99
- return (
100
- <button onClick={() => reconnect.schedule("manual")}>
101
- Retry now
102
- </button>
103
- );
104
- };
105
- ```
106
-
107
- ### useHeartbeat
108
-
109
- ```tsx
110
- import { useHeartbeat } from "react-realtime-hooks";
111
-
112
- export const HeartbeatPanel = () => {
113
- const heartbeat = useHeartbeat<string, string>({
114
- intervalMs: 5_000,
115
- matchesAck: (message) => message === "pong",
116
- startOnMount: true,
117
- timeoutMs: 2_000
118
- });
119
-
120
- return (
121
- <div>
122
- running: {String(heartbeat.isRunning)} | latency: {heartbeat.latencyMs ?? "n/a"}
123
- </div>
124
- );
125
- };
126
- ```
127
-
128
- ### useWebSocket
129
-
130
- ```tsx
131
- import { useWebSocket } from "react-realtime-hooks";
132
-
133
- type IncomingMessage = {
134
- type: "chat" | "system";
135
- text: string;
136
- };
137
-
138
- type OutgoingMessage = {
139
- type: "ping" | "chat";
140
- text?: string;
141
- };
142
-
143
- export const ChatSocket = () => {
144
- const socket = useWebSocket<IncomingMessage, OutgoingMessage>({
145
- url: "ws://localhost:8080",
146
- heartbeat: {
147
- intervalMs: 10_000,
148
- matchesAck: (message) => message.type === "system" && message.text === "pong",
149
- message: { type: "ping" },
150
- timeoutMs: 3_000
151
- },
152
- parseMessage: (event) => JSON.parse(String(event.data)) as IncomingMessage,
153
- reconnect: {
154
- initialDelayMs: 1_000,
155
- maxAttempts: null
156
- }
157
- });
158
-
159
- return (
160
- <button onClick={() => socket.send({ text: "Hello", type: "chat" })}>
161
- Send
162
- </button>
163
- );
164
- };
165
- ```
166
-
167
- ### useEventSource
168
-
169
- ```tsx
170
- import { useEventSource } from "react-realtime-hooks";
171
-
172
- type FeedItem = {
173
- id: string;
174
- level: "info" | "warn";
175
- text: string;
176
- };
177
-
178
- export const LiveFeed = () => {
179
- const feed = useEventSource<FeedItem>({
180
- events: ["notice"],
181
- parseMessage: (event) => JSON.parse(event.data) as FeedItem,
182
- reconnect: {
183
- initialDelayMs: 1_000,
184
- maxAttempts: 10
185
- },
186
- url: "http://localhost:8080/sse"
187
- });
188
-
189
- return (
190
- <div>
191
- {feed.lastEventName}: {feed.lastMessage?.text ?? "Waiting for updates"}
192
- </div>
193
- );
194
- };
195
- ```
196
-
197
- ## API
198
-
199
- ### useOnlineStatus
200
-
201
- #### Options
202
-
203
- | Option | Type | Default | Description |
204
- | --- | --- | --- | --- |
205
- | `initialOnline` | `boolean` | `true` | Fallback value when `navigator.onLine` is unavailable |
206
- | `trackTransitions` | `boolean` | `true` | Tracks `lastChangedAt`, `wentOnlineAt`, `wentOfflineAt` |
207
-
208
- #### Result
209
-
210
- | Field | Type | Description |
211
- | --- | --- | --- |
212
- | `isOnline` | `boolean` | Current browser online state |
213
- | `isSupported` | `boolean` | Whether `navigator.onLine` is available |
214
- | `lastChangedAt` | `number \| null` | Timestamp of the last transition |
215
- | `wentOnlineAt` | `number \| null` | Timestamp of the last online transition |
216
- | `wentOfflineAt` | `number \| null` | Timestamp of the last offline transition |
217
-
218
- ### useReconnect
219
-
220
- #### Options
221
-
222
- | Option | Type | Default | Description |
223
- | --- | --- | --- | --- |
224
- | `enabled` | `boolean` | `true` | Enables scheduling attempts |
225
- | `initialDelayMs` | `number` | `1000` | Delay for the first attempt |
226
- | `maxDelayMs` | `number` | `30000` | Delay cap |
227
- | `backoffFactor` | `number` | `2` | Exponential multiplier |
228
- | `jitterRatio` | `number` | `0.2` | Randomized variance ratio |
229
- | `maxAttempts` | `number \| null` | `null` | Max attempts, `null` means unlimited |
230
- | `getDelayMs` | `ReconnectDelayStrategy` | `undefined` | Custom delay strategy |
231
- | `resetOnSuccess` | `boolean` | `true` | Resets attempt count after success |
232
- | `onSchedule` | `(attempt) => void` | `undefined` | Called when an attempt is scheduled |
233
- | `onCancel` | `() => void` | `undefined` | Called when scheduling is canceled |
234
- | `onReset` | `() => void` | `undefined` | Called when state is reset |
235
-
236
- #### Result
237
-
238
- | Field | Type | Description |
239
- | --- | --- | --- |
240
- | `status` | `"idle" \| "scheduled" \| "running" \| "stopped"` | Current reconnect state |
241
- | `attempt` | `number` | Current attempt number |
242
- | `nextDelayMs` | `number \| null` | Delay of the scheduled attempt |
243
- | `isActive` | `boolean` | `true` when scheduled or running |
244
- | `isScheduled` | `boolean` | `true` when waiting for the next attempt |
245
- | `schedule` | `(trigger?) => void` | Schedules an attempt |
246
- | `cancel` | `() => void` | Cancels the current schedule |
247
- | `reset` | `() => void` | Resets attempts and status |
248
- | `markConnected` | `() => void` | Marks the transport as restored |
249
-
250
- ### useHeartbeat
251
-
252
- #### Options
253
-
254
- | Option | Type | Default | Description |
255
- | --- | --- | --- | --- |
256
- | `enabled` | `boolean` | `true` | Enables the heartbeat loop |
257
- | `intervalMs` | `number` | Required | Beat interval |
258
- | `timeoutMs` | `number` | `undefined` | Timeout before `hasTimedOut` becomes `true` |
259
- | `message` | `TOutgoing \| (() => TOutgoing)` | `undefined` | Optional heartbeat payload |
260
- | `beat` | `() => void \| boolean \| Promise<void \| boolean>` | `undefined` | Custom beat side effect |
261
- | `matchesAck` | `(message) => boolean` | `undefined` | Ack matcher |
262
- | `startOnMount` | `boolean` | `true` | Starts immediately |
263
- | `onBeat` | `() => void` | `undefined` | Called on every beat |
264
- | `onTimeout` | `() => void` | `undefined` | Called on timeout |
265
-
266
- #### Result
267
-
268
- | Field | Type | Description |
269
- | --- | --- | --- |
270
- | `isRunning` | `boolean` | Whether the loop is active |
271
- | `hasTimedOut` | `boolean` | Whether the latest beat timed out |
272
- | `lastBeatAt` | `number \| null` | Last beat timestamp |
273
- | `lastAckAt` | `number \| null` | Last ack timestamp |
274
- | `latencyMs` | `number \| null` | Ack latency |
275
- | `start` | `() => void` | Starts the loop |
276
- | `stop` | `() => void` | Stops the loop |
277
- | `beat` | `() => void` | Triggers a manual beat |
278
- | `notifyAck` | `(message) => boolean` | Applies an incoming ack message |
279
-
280
- ### useWebSocket
281
-
282
- #### Options
283
-
284
- | Option | Type | Default | Description |
285
- | --- | --- | --- | --- |
286
- | `url` | `UrlProvider` | Required | String, `URL`, or lazy URL factory |
287
- | `protocols` | `string \| string[]` | `undefined` | WebSocket subprotocols |
288
- | `connect` | `boolean` | `true` | Auto-connect on mount |
289
- | `binaryType` | `BinaryType` | `"blob"` | Socket binary mode |
290
- | `parseMessage` | `(event) => TIncoming` | raw `event.data` | Incoming parser |
291
- | `serializeMessage` | `(message) => ...` | JSON/string passthrough | Outgoing serializer |
292
- | `reconnect` | `false \| UseReconnectOptions` | enabled | Reconnect configuration |
293
- | `heartbeat` | `false \| UseHeartbeatOptions` | disabled unless configured | Heartbeat configuration |
294
- | `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on close |
295
- | `onOpen` | `(event, socket) => void` | `undefined` | Open callback |
296
- | `onMessage` | `(message, event) => void` | `undefined` | Message callback |
297
- | `onError` | `(event) => void` | `undefined` | Error callback |
298
- | `onClose` | `(event) => void` | `undefined` | Close callback |
299
-
300
- #### Result
301
-
302
- | Field | Type | Description |
303
- | --- | --- | --- |
304
- | `status` | connection union | `idle`, `connecting`, `open`, `closing`, `closed`, `reconnecting`, `error` |
305
- | `socket` | `WebSocket \| null` | Current transport instance |
306
- | `lastMessage` | `TIncoming \| null` | Last parsed message |
307
- | `lastCloseEvent` | `CloseEvent \| null` | Last close event |
308
- | `lastError` | `Event \| null` | Last error |
309
- | `bufferedAmount` | `number` | Current socket buffer size |
310
- | `reconnectState` | reconnect snapshot or `null` | Current reconnect data |
311
- | `heartbeatState` | heartbeat snapshot or `null` | Current heartbeat data |
312
- | `open` | `() => void` | Manual connect |
313
- | `close` | `(code?, reason?) => void` | Manual close |
314
- | `reconnect` | `() => void` | Manual reconnect |
315
- | `send` | `(message) => boolean` | Sends an outgoing payload |
316
-
317
- ### useEventSource
318
-
319
- #### Options
320
-
321
- | Option | Type | Default | Description |
322
- | --- | --- | --- | --- |
323
- | `url` | `UrlProvider` | Required | String, `URL`, or lazy URL factory |
324
- | `withCredentials` | `boolean` | `false` | Passes credentials to `EventSource` |
325
- | `connect` | `boolean` | `true` | Auto-connect on mount |
326
- | `events` | `readonly string[]` | `undefined` | Named SSE events to subscribe to |
327
- | `parseMessage` | `(event) => TMessage` | raw `event.data` | Incoming parser |
328
- | `reconnect` | `false \| UseReconnectOptions` | enabled | Reconnect configuration |
329
- | `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on error |
330
- | `onOpen` | `(event, source) => void` | `undefined` | Open callback |
331
- | `onMessage` | `(message, event) => void` | `undefined` | Default `message` callback |
332
- | `onError` | `(event) => void` | `undefined` | Error callback |
333
- | `onEvent` | `(eventName, message, event) => void` | `undefined` | Named event callback |
334
-
335
- #### Result
336
-
337
- | Field | Type | Description |
338
- | --- | --- | --- |
339
- | `status` | connection union | `idle`, `connecting`, `open`, `closing`, `closed`, `reconnecting`, `error` |
340
- | `eventSource` | `EventSource \| null` | Current transport instance |
341
- | `lastEventName` | `string \| null` | Last SSE event name |
342
- | `lastMessage` | `TMessage \| null` | Last parsed payload |
343
- | `lastError` | `Event \| null` | Last error |
344
- | `reconnectState` | reconnect snapshot or `null` | Current reconnect data |
345
- | `open` | `() => void` | Manual connect |
346
- | `close` | `() => void` | Manual close |
347
- | `reconnect` | `() => void` | Manual reconnect |
348
-
349
- ## Status Model
350
-
351
- The transport hooks return discriminated connection snapshots:
352
-
353
- - `open`: connected
354
- - `connecting`: opening the first connection
355
- - `reconnecting`: reconnect flow is in progress
356
- - `closing`: explicit close is in progress
357
- - `closed`: transport was explicitly closed or cannot continue
358
- - `idle`: auto-connect is disabled and nothing is currently opening
359
- - `error`: the hook encountered an unrecoverable parse/runtime error
360
-
361
- ## Limitations And Edge Cases
362
-
363
- - `useEventSource` is receive-only by design. SSE is not a bidirectional transport.
364
- - `useWebSocket` heartbeat logic is client-side. It does not define your server ping/pong protocol for you.
365
- - If `parseMessage` throws, the hook moves into `error` and stores `lastError`.
366
- - `connect: false` means the hook stays idle until `open()` is called.
367
- - Manual `close()` is sticky: the hook stays closed until you call `open()` or `reconnect()`.
368
- - On the server, transport hooks do not open real connections. They stay SSR-safe and connect only in the browser.
369
- - Browser-native `WebSocket` and `EventSource` behavior still applies: proxy issues, auth constraints, and network policies are outside the hook’s control.
370
- - `EventSource` named events are additive. The hook always listens to the default `message` channel.
371
- - No transport polyfills are bundled. If you target unsupported environments, provide your own runtime/polyfill.
372
-
373
- ## Testing
374
-
375
- The package includes behavior tests for:
376
-
377
- - connect / disconnect / reconnect
378
- - exponential backoff
379
- - cleanup of timers and listeners
380
- - heartbeat start / stop / timeout
381
- - browser offline / online transitions
382
- - invalid payload / parse errors
383
- - manual reconnect / manual close
384
-
385
- `WebSocket` and `EventSource` are tested through mocked browser APIs.
386
-
387
- ## Demo
388
-
389
- Live playground:
390
-
391
- - https://volkov85.github.io/react-realtime-hooks/
392
-
393
- Run the local playground:
394
-
395
- ```bash
396
- npm run demo
397
- ```
398
-
399
- The demo includes separate blocks for:
400
-
401
- - `useOnlineStatus`
402
- - `useReconnect`
403
- - `useHeartbeat`
404
- - `useWebSocket`
405
- - `useEventSource`
406
-
407
- Notes:
408
-
409
- - `useWebSocket` and `useEventSource` are exposed as playground blocks with manual URL input.
410
- - Browser-only hooks still require real endpoints if you want to test transport connectivity in the hosted demo.
411
-
412
- ## Development
413
-
414
- Available scripts:
415
-
416
- ```bash
417
- npm run build
418
- npm run demo
419
- npm run demo:build
420
- npm run lint
421
- npm run typecheck
422
- npm run test
423
- npm run publint
424
- ```
425
-
426
- ## Changelog And Releases
427
-
428
- This repo uses Changesets for versioning, changelog generation, and npm publishing.
429
-
430
- ### Local workflow
431
-
432
- Create a changeset for user-facing changes:
433
-
434
- ```bash
435
- npm run changeset
436
- ```
437
-
438
- Version packages locally:
439
-
440
- ```bash
441
- npm run version-packages
442
- ```
443
-
444
- Publish manually:
445
-
446
- ```bash
447
- npm run release
448
- ```
449
-
450
- ### CI release workflow
451
-
452
- - Feature work lands in `main`
453
- - A Changesets release PR is created automatically
454
- - When that PR is merged, Changesets publishes to npm
455
- - Changelog entries are generated from the changeset summaries
456
-
457
- Required GitHub secrets:
458
-
459
- - `NPM_TOKEN`
460
-
461
- ## CI
462
-
463
- Quality gate runs on pushes and pull requests:
464
-
465
- - `npm run typecheck`
466
- - `npm run lint`
467
- - `npm run test`
468
- - `npm run build`
469
- - `npm run demo:build`
470
- - `npm run publint`
471
-
472
- ## License
473
-
474
- MIT
1
+ # react-realtime-hooks
2
+
3
+ [![npm version](https://img.shields.io/npm/v/react-realtime-hooks?color=0f766e)](https://www.npmjs.com/package/react-realtime-hooks)
4
+ [![Quality Gate](https://img.shields.io/github/actions/workflow/status/volkov85/react-realtime-hooks/quality-gate.yml?branch=main&label=quality%20gate)](https://github.com/volkov85/react-realtime-hooks/actions/workflows/quality-gate.yml)
5
+ [![Demo](https://img.shields.io/github/actions/workflow/status/volkov85/react-realtime-hooks/pages.yml?branch=main&label=demo)](https://github.com/volkov85/react-realtime-hooks/actions/workflows/pages.yml)
6
+ [![license](https://img.shields.io/npm/l/react-realtime-hooks)](https://github.com/volkov85/react-realtime-hooks/blob/main/LICENSE)
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-typed-3178c6)](https://www.typescriptlang.org/)
8
+ [![react](https://img.shields.io/badge/react-18.3%2B%20%7C%2019-149eca)](https://www.npmjs.com/package/react)
9
+
10
+ Production-ready React hooks for WebSocket and SSE with auto-reconnect, heartbeat, typed connection state, and browser network awareness.
11
+
12
+ `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, and online status, so your UI can react to realtime state without rebuilding the same connection logic in every screen.
13
+
14
+ Live demo: https://volkov85.github.io/react-realtime-hooks/
15
+
16
+ ## Why This Library
17
+
18
+ Most realtime helpers stop at transport setup.
19
+
20
+ Real apps need:
21
+
22
+ - explicit `connecting` / `reconnecting` / `closed` / `error` states
23
+ - reconnect strategy with caps, jitter, and manual control
24
+ - heartbeat and timeout tracking
25
+ - clean SSR behavior
26
+ - browser network awareness
27
+ - typed message parsing and sending
28
+
29
+ `react-realtime-hooks` packages those concerns into small hooks that compose cleanly in React.
30
+
31
+ ## Killer Features
32
+
33
+ - `useWebSocket` and `useEventSource` return state you can render, not just transport instances.
34
+ - Built-in reconnect flow with exponential backoff, jitter, attempt limits, and manual restart.
35
+ - Heartbeat support with ack matching, timeout detection, and latency measurement.
36
+ - Discriminated connection snapshots: `idle`, `connecting`, `open`, `reconnecting`, `closing`, `closed`, `error`.
37
+ - First-class TypeScript support with generic message types and custom parsers/serializers.
38
+ - SSR-safe by default. No browser-only globals are touched during server render.
39
+ - Zero runtime dependencies beyond React.
40
+ - Manual controls stay available when you need them: `open()`, `close()`, `reconnect()`, `send()`.
41
+
42
+ ## Raw WebSocket vs This Library
43
+
44
+ | Concern | Raw WebSocket | `react-realtime-hooks` |
45
+ | ----------------- | ---------------------------------- | ------------------------------------------------ |
46
+ | Connection state | You model it yourself | Built-in status model you can render directly |
47
+ | Reconnect flow | Manual timers and teardown | `useReconnect` with backoff, jitter, and limits |
48
+ | Heartbeat | Custom ping/pong loop | `heartbeat` support with timeout and latency |
49
+ | Network awareness | Separate browser event wiring | `useOnlineStatus` for online/offline state |
50
+ | SSR safety | Easy to break during render | Browser-only behavior stays out of server render |
51
+ | UI ergonomics | Event handlers and refs everywhere | Hook result already shaped for product UI |
52
+
53
+ The point is not to hide WebSocket. The point is to stop rewriting the same lifecycle machinery around it.
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ npm install react-realtime-hooks
59
+ ```
60
+
61
+ Peer dependency:
62
+
63
+ - `react@^18.3.0 || ^19.0.0`
64
+
65
+ ## How It Feels
66
+
67
+ ```tsx
68
+ import { useOnlineStatus, useWebSocket } from "react-realtime-hooks";
69
+
70
+ type IncomingMessage =
71
+ | { type: "notification"; text: string }
72
+ | { type: "pong" };
73
+
74
+ type OutgoingMessage = { type: "ack"; id: string } | { type: "ping" };
75
+
76
+ export function NotificationsPanel() {
77
+ const network = useOnlineStatus();
78
+ const socket = useWebSocket<IncomingMessage, OutgoingMessage>({
79
+ url: "ws://localhost:8080/notifications",
80
+ parseMessage: (event) => JSON.parse(String(event.data)) as IncomingMessage,
81
+ reconnect: {
82
+ initialDelayMs: 1_000,
83
+ maxAttempts: null,
84
+ },
85
+ heartbeat: {
86
+ intervalMs: 10_000,
87
+ timeoutMs: 3_000,
88
+ message: { type: "ping" },
89
+ matchesAck: (message) => message.type === "pong",
90
+ },
91
+ });
92
+
93
+ return (
94
+ <section>
95
+ <p>
96
+ Network: {network.isOnline ? "online" : "offline"} | Transport:{" "}
97
+ {socket.status}
98
+ </p>
99
+
100
+ {socket.status === "reconnecting" && (
101
+ <p>Retrying in {socket.reconnectState?.nextDelayMs ?? 0}ms</p>
102
+ )}
103
+
104
+ {socket.heartbeatState?.hasTimedOut && <p>Heartbeat timed out</p>}
105
+
106
+ <button
107
+ disabled={socket.status !== "open"}
108
+ onClick={() => socket.send({ type: "ack", id: "msg-42" })}
109
+ >
110
+ Ack latest
111
+ </button>
112
+
113
+ <pre>{JSON.stringify(socket.lastMessage, null, 2)}</pre>
114
+ </section>
115
+ );
116
+ }
117
+ ```
118
+
119
+ You are not wiring raw `onopen`, `onclose`, and timer cleanup by hand. You render the current transport state and keep moving.
120
+
121
+ ## Status-First UX
122
+
123
+ The transport hooks return a discriminated status model, so UI states stay explicit instead of collapsing into a vague `isConnected` boolean.
124
+
125
+ - `idle`: auto-connect is off and nothing is opening
126
+ - `connecting`: first connection attempt is in progress
127
+ - `open`: transport is live
128
+ - `reconnecting`: retry flow is active
129
+ - `closing`: explicit close is in progress
130
+ - `closed`: transport is stopped and will not continue
131
+ - `error`: an unrecoverable parse/runtime error occurred
132
+
133
+ That makes product UI straightforward:
134
+
135
+ - show a retry banner on `reconnecting`
136
+ - disable send buttons unless `status === "open"`
137
+ - show offline or degraded indicators without guessing
138
+ - surface heartbeat timeout separately from transport close
139
+
140
+ ## Architecture Idea
141
+
142
+ This library is built as layered primitives, not one giant "magic realtime client".
143
+
144
+ ```text
145
+ Browser APIs
146
+ WebSocket / EventSource / navigator.onLine
147
+
148
+ Core hooks
149
+ useReconnect / useHeartbeat / useOnlineStatus
150
+
151
+ Transport hooks
152
+ useWebSocket / useEventSource
153
+
154
+ UI
155
+ banners, badges, retry states, feed views, chat inputs
156
+ ```
157
+
158
+ That separation matters:
159
+
160
+ - you can use `useReconnect` and `useHeartbeat` outside the transport hooks
161
+ - transport hooks stay predictable instead of hiding lifecycle decisions
162
+ - the UI gets a stable state model instead of raw event listeners
163
+
164
+ ## Real-World Use Cases
165
+
166
+ - Chat and support widgets that need reconnect and delivery-aware UI
167
+ - Notification centers and activity feeds over WebSocket
168
+ - Live dashboards and ops consoles consuming SSE streams
169
+ - Trading, analytics, and monitoring UIs with explicit connection states
170
+ - Device and IoT panels that need heartbeat and timeout visibility
171
+ - Collaborative tools that must reflect degraded or reconnecting transport state
172
+
173
+ ## Anti-Features
174
+
175
+ This package is intentionally not trying to be a full client platform.
176
+
177
+ - No bundled transport polyfills
178
+ - No opinionated server protocol
179
+ - No hidden global singleton connection manager
180
+ - No built-in auth refresh flow
181
+ - No state management framework or cache layer
182
+ - No "smart" abstractions that erase transport state details
183
+
184
+ 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.
185
+
186
+ ## Why Not Write It Yourself?
187
+
188
+ Because "just a socket hook" turns into more work than it looks like:
189
+
190
+ - reconnect timers need careful cleanup and manual-close semantics
191
+ - heartbeat loops need ack matching, timeout handling, and teardown discipline
192
+ - URL changes and remounts create subtle race conditions
193
+ - SSR breaks if browser globals leak into render
194
+ - a single `isOpen` flag is not enough for real UI states
195
+ - parse failures and transport errors need consistent state transitions
196
+
197
+ This library already models those edges in a reusable way.
198
+
199
+ ## API At A Glance
200
+
201
+ | Hook | Use it for | Returns |
202
+ | ----------------- | ------------------------------------ | ---------------------------------------------------------------------------- |
203
+ | `useWebSocket` | Bidirectional realtime channels | `status`, `socket`, `lastMessage`, `send()`, `reconnect()`, `heartbeatState` |
204
+ | `useEventSource` | Server-Sent Events streams | `status`, `eventSource`, `lastMessage`, `lastEventName`, `reconnect()` |
205
+ | `useReconnect` | Reusable retry and backoff logic | `schedule()`, `cancel()`, `reset()`, `attempt`, `status` |
206
+ | `useHeartbeat` | Liveness checks and timeout tracking | `start()`, `stop()`, `beat()`, `notifyAck()`, `latencyMs` |
207
+ | `useOnlineStatus` | Browser online/offline state | `isOnline`, `isSupported`, transition timestamps |
208
+
209
+ ## Transport Examples
210
+
211
+ ### `useWebSocket`
212
+
213
+ ```tsx
214
+ import { useWebSocket } from "react-realtime-hooks";
215
+
216
+ type IncomingMessage = {
217
+ type: "chat" | "system";
218
+ text: string;
219
+ };
220
+
221
+ type OutgoingMessage = {
222
+ type: "ping" | "chat";
223
+ text?: string;
224
+ };
225
+
226
+ export function ChatSocket() {
227
+ const socket = useWebSocket<IncomingMessage, OutgoingMessage>({
228
+ url: "ws://localhost:8080",
229
+ parseMessage: (event) => JSON.parse(String(event.data)) as IncomingMessage,
230
+ reconnect: {
231
+ initialDelayMs: 1_000,
232
+ maxAttempts: null,
233
+ },
234
+ heartbeat: {
235
+ intervalMs: 10_000,
236
+ timeoutMs: 3_000,
237
+ message: { type: "ping" },
238
+ matchesAck: (message) =>
239
+ message.type === "system" && message.text === "pong",
240
+ },
241
+ });
242
+
243
+ return (
244
+ <button onClick={() => socket.send({ type: "chat", text: "Hello" })}>
245
+ Send
246
+ </button>
247
+ );
248
+ }
249
+ ```
250
+
251
+ ### `useEventSource`
252
+
253
+ ```tsx
254
+ import { useEventSource } from "react-realtime-hooks";
255
+
256
+ type FeedItem = {
257
+ id: string;
258
+ level: "info" | "warn";
259
+ text: string;
260
+ };
261
+
262
+ export function LiveFeed() {
263
+ const feed = useEventSource<FeedItem>({
264
+ url: "http://localhost:8080/sse",
265
+ events: ["notice"],
266
+ parseMessage: (event) => JSON.parse(event.data) as FeedItem,
267
+ reconnect: {
268
+ initialDelayMs: 1_000,
269
+ maxAttempts: 10,
270
+ },
271
+ });
272
+
273
+ return (
274
+ <div>
275
+ {feed.lastEventName}: {feed.lastMessage?.text ?? "Waiting for updates"}
276
+ </div>
277
+ );
278
+ }
279
+ ```
280
+
281
+ ## Core Hook Examples
282
+
283
+ ### `useReconnect`
284
+
285
+ ```tsx
286
+ import { useReconnect } from "react-realtime-hooks";
287
+
288
+ export function RetryPanel() {
289
+ const reconnect = useReconnect({
290
+ initialDelayMs: 1_000,
291
+ maxAttempts: 5,
292
+ jitterRatio: 0,
293
+ });
294
+
295
+ return (
296
+ <button onClick={() => reconnect.schedule("manual")}>Retry now</button>
297
+ );
298
+ }
299
+ ```
300
+
301
+ ### `useHeartbeat`
302
+
303
+ ```tsx
304
+ import { useHeartbeat } from "react-realtime-hooks";
305
+
306
+ export function HeartbeatPanel() {
307
+ const heartbeat = useHeartbeat<string, string>({
308
+ intervalMs: 5_000,
309
+ timeoutMs: 2_000,
310
+ startOnMount: true,
311
+ matchesAck: (message) => message === "pong",
312
+ });
313
+
314
+ return (
315
+ <div>
316
+ running: {String(heartbeat.isRunning)} | latency:{" "}
317
+ {heartbeat.latencyMs ?? "n/a"}
318
+ </div>
319
+ );
320
+ }
321
+ ```
322
+
323
+ ### `useOnlineStatus`
324
+
325
+ ```tsx
326
+ import { useOnlineStatus } from "react-realtime-hooks";
327
+
328
+ export function NetworkIndicator() {
329
+ const network = useOnlineStatus({
330
+ trackTransitions: true,
331
+ });
332
+
333
+ return <span>{network.isOnline ? "Online" : "Offline"}</span>;
334
+ }
335
+ ```
336
+
337
+ ## API Reference
338
+
339
+ <details>
340
+ <summary><strong>useWebSocket</strong></summary>
341
+
342
+ ### Options
343
+
344
+ | Option | Type | Default | Description |
345
+ | ------------------ | ------------------------------ | -------------------------- | ---------------------------------- |
346
+ | `url` | `UrlProvider` | Required | String, `URL`, or lazy URL factory |
347
+ | `protocols` | `string \| string[]` | `undefined` | WebSocket subprotocols |
348
+ | `connect` | `boolean` | `true` | Auto-connect on mount |
349
+ | `binaryType` | `BinaryType` | `"blob"` | Socket binary mode |
350
+ | `parseMessage` | `(event) => TIncoming` | raw `event.data` | Incoming parser |
351
+ | `serializeMessage` | `(message) => ...` | JSON/string passthrough | Outgoing serializer |
352
+ | `reconnect` | `false \| UseReconnectOptions` | enabled | Reconnect configuration |
353
+ | `heartbeat` | `false \| UseHeartbeatOptions` | disabled unless configured | Heartbeat configuration |
354
+ | `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on close |
355
+ | `onOpen` | `(event, socket) => void` | `undefined` | Open callback |
356
+ | `onMessage` | `(message, event) => void` | `undefined` | Message callback |
357
+ | `onError` | `(event) => void` | `undefined` | Error callback |
358
+ | `onClose` | `(event) => void` | `undefined` | Close callback |
359
+
360
+ ### Result
361
+
362
+ | Field | Type | Description |
363
+ | ------------------ | ---------------------------- | -------------------------------------------------------------------------- |
364
+ | `status` | connection union | `idle`, `connecting`, `open`, `closing`, `closed`, `reconnecting`, `error` |
365
+ | `socket` | `WebSocket \| null` | Current transport instance |
366
+ | `lastMessage` | `TIncoming \| null` | Last parsed message |
367
+ | `lastMessageEvent` | `MessageEvent \| null` | Last raw message event |
368
+ | `lastCloseEvent` | `CloseEvent \| null` | Last close event |
369
+ | `lastError` | `Event \| null` | Last error |
370
+ | `bufferedAmount` | `number` | Current socket buffer size |
371
+ | `reconnectState` | reconnect snapshot or `null` | Current reconnect data |
372
+ | `heartbeatState` | heartbeat snapshot or `null` | Current heartbeat data |
373
+ | `open` | `() => void` | Manual connect |
374
+ | `close` | `(code?, reason?) => void` | Manual close |
375
+ | `reconnect` | `() => void` | Manual reconnect |
376
+ | `send` | `(message) => boolean` | Sends an outgoing payload |
377
+
378
+ </details>
379
+
380
+ <details>
381
+ <summary><strong>useEventSource</strong></summary>
382
+
383
+ ### Options
384
+
385
+ | Option | Type | Default | Description |
386
+ | ----------------- | ------------------------------------- | ---------------- | ----------------------------------- |
387
+ | `url` | `UrlProvider` | Required | String, `URL`, or lazy URL factory |
388
+ | `withCredentials` | `boolean` | `false` | Passes credentials to `EventSource` |
389
+ | `connect` | `boolean` | `true` | Auto-connect on mount |
390
+ | `events` | `readonly string[]` | `undefined` | Named SSE events to subscribe to |
391
+ | `parseMessage` | `(event) => TMessage` | raw `event.data` | Incoming parser |
392
+ | `reconnect` | `false \| UseReconnectOptions` | enabled | Reconnect configuration |
393
+ | `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on error |
394
+ | `onOpen` | `(event, source) => void` | `undefined` | Open callback |
395
+ | `onMessage` | `(message, event) => void` | `undefined` | Default `message` callback |
396
+ | `onError` | `(event) => void` | `undefined` | Error callback |
397
+ | `onEvent` | `(eventName, message, event) => void` | `undefined` | Named event callback |
398
+
399
+ ### Result
400
+
401
+ | Field | Type | Description |
402
+ | ------------------ | ---------------------------- | -------------------------------------------------------------------------- |
403
+ | `status` | connection union | `idle`, `connecting`, `open`, `closing`, `closed`, `reconnecting`, `error` |
404
+ | `eventSource` | `EventSource \| null` | Current transport instance |
405
+ | `lastEventName` | `string \| null` | Last SSE event name |
406
+ | `lastMessage` | `TMessage \| null` | Last parsed payload |
407
+ | `lastMessageEvent` | `MessageEvent \| null` | Last raw message event |
408
+ | `lastError` | `Event \| null` | Last error |
409
+ | `reconnectState` | reconnect snapshot or `null` | Current reconnect data |
410
+ | `open` | `() => void` | Manual connect |
411
+ | `close` | `() => void` | Manual close |
412
+ | `reconnect` | `() => void` | Manual reconnect |
413
+
414
+ </details>
415
+
416
+ <details>
417
+ <summary><strong>useReconnect</strong></summary>
418
+
419
+ ### Options
420
+
421
+ | Option | Type | Default | Description |
422
+ | ---------------- | ------------------------ | ----------- | ------------------------------------ |
423
+ | `enabled` | `boolean` | `true` | Enables scheduling attempts |
424
+ | `initialDelayMs` | `number` | `1000` | Delay for the first attempt |
425
+ | `maxDelayMs` | `number` | `30000` | Delay cap |
426
+ | `backoffFactor` | `number` | `2` | Exponential multiplier |
427
+ | `jitterRatio` | `number` | `0.2` | Randomized variance ratio |
428
+ | `maxAttempts` | `number \| null` | `null` | Max attempts, `null` means unlimited |
429
+ | `getDelayMs` | `ReconnectDelayStrategy` | `undefined` | Custom delay strategy |
430
+ | `resetOnSuccess` | `boolean` | `true` | Resets attempt count after success |
431
+ | `onSchedule` | `(attempt) => void` | `undefined` | Called when an attempt is scheduled |
432
+ | `onCancel` | `() => void` | `undefined` | Called when scheduling is canceled |
433
+ | `onReset` | `() => void` | `undefined` | Called when state is reset |
434
+
435
+ ### Result
436
+
437
+ | Field | Type | Description |
438
+ | --------------- | ------------------------------------------------- | ---------------------------------------- |
439
+ | `status` | `"idle" \| "scheduled" \| "running" \| "stopped"` | Current reconnect state |
440
+ | `attempt` | `number` | Current attempt number |
441
+ | `nextDelayMs` | `number \| null` | Delay of the scheduled attempt |
442
+ | `isActive` | `boolean` | `true` when scheduled or running |
443
+ | `isScheduled` | `boolean` | `true` when waiting for the next attempt |
444
+ | `schedule` | `(trigger?) => void` | Schedules an attempt |
445
+ | `cancel` | `() => void` | Cancels the current schedule |
446
+ | `reset` | `() => void` | Resets attempts and status |
447
+ | `markConnected` | `() => void` | Marks the transport as restored |
448
+
449
+ </details>
450
+
451
+ <details>
452
+ <summary><strong>useHeartbeat</strong></summary>
453
+
454
+ ### Options
455
+
456
+ | Option | Type | Default | Description |
457
+ | -------------- | --------------------------------------------------- | ----------- | ------------------------------------------- |
458
+ | `enabled` | `boolean` | `true` | Enables the heartbeat loop |
459
+ | `intervalMs` | `number` | Required | Beat interval |
460
+ | `timeoutMs` | `number` | `undefined` | Timeout before `hasTimedOut` becomes `true` |
461
+ | `message` | `TOutgoing \| (() => TOutgoing)` | `undefined` | Optional heartbeat payload |
462
+ | `beat` | `() => void \| boolean \| Promise<void \| boolean>` | `undefined` | Custom beat side effect |
463
+ | `matchesAck` | `(message) => boolean` | `undefined` | Ack matcher |
464
+ | `startOnMount` | `boolean` | `true` | Starts immediately |
465
+ | `onBeat` | `() => void` | `undefined` | Called on every beat |
466
+ | `onTimeout` | `() => void` | `undefined` | Called on timeout |
467
+
468
+ ### Result
469
+
470
+ | Field | Type | Description |
471
+ | ------------- | ---------------------- | --------------------------------- |
472
+ | `isRunning` | `boolean` | Whether the loop is active |
473
+ | `hasTimedOut` | `boolean` | Whether the latest beat timed out |
474
+ | `lastBeatAt` | `number \| null` | Last beat timestamp |
475
+ | `lastAckAt` | `number \| null` | Last ack timestamp |
476
+ | `latencyMs` | `number \| null` | Ack latency |
477
+ | `start` | `() => void` | Starts the loop |
478
+ | `stop` | `() => void` | Stops the loop |
479
+ | `beat` | `() => void` | Triggers a manual beat |
480
+ | `notifyAck` | `(message) => boolean` | Applies an incoming ack message |
481
+
482
+ </details>
483
+
484
+ <details>
485
+ <summary><strong>useOnlineStatus</strong></summary>
486
+
487
+ ### Options
488
+
489
+ | Option | Type | Default | Description |
490
+ | ------------------ | --------- | ------- | ------------------------------------------------------- |
491
+ | `initialOnline` | `boolean` | `true` | Fallback value when `navigator.onLine` is unavailable |
492
+ | `trackTransitions` | `boolean` | `true` | Tracks `lastChangedAt`, `wentOnlineAt`, `wentOfflineAt` |
493
+
494
+ ### Result
495
+
496
+ | Field | Type | Description |
497
+ | --------------- | ---------------- | ---------------------------------------- |
498
+ | `isOnline` | `boolean` | Current browser online state |
499
+ | `isSupported` | `boolean` | Whether `navigator.onLine` is available |
500
+ | `lastChangedAt` | `number \| null` | Timestamp of the last transition |
501
+ | `wentOnlineAt` | `number \| null` | Timestamp of the last online transition |
502
+ | `wentOfflineAt` | `number \| null` | Timestamp of the last offline transition |
503
+
504
+ </details>
505
+
506
+ ## Limitations And Edge Cases
507
+
508
+ - `useEventSource` is receive-only by design. SSE is not a bidirectional transport.
509
+ - `useWebSocket` heartbeat support is client-side. You still define your own server ping/pong protocol.
510
+ - If `parseMessage` throws, the hook closes the current transport, moves into `error`, stores `lastError`, and stops auto-reconnect until manual `open()` or `reconnect()`.
511
+ - `connect: false` keeps the hook in `idle` until `open()` is called.
512
+ - Manual `close()` is sticky. The hook stays closed until `open()` or `reconnect()` is called.
513
+ - No transport polyfills are bundled. Provide your own runtime support where needed.
514
+ - Browser-native transport constraints still apply: auth, proxy, CORS, and network policy are outside the hook's control.
515
+
516
+ ## Testing And Quality
517
+
518
+ The package includes behavior tests for:
519
+
520
+ - connect / disconnect / reconnect
521
+ - exponential backoff
522
+ - timer and listener cleanup
523
+ - heartbeat start / stop / timeout
524
+ - browser offline / online transitions
525
+ - invalid payload and parse errors
526
+ - manual reconnect and manual close
527
+
528
+ `WebSocket` and `EventSource` are tested through mocked browser APIs.
529
+
530
+ ## Demo
531
+
532
+ - Live demo: https://volkov85.github.io/react-realtime-hooks/
533
+ - Repository: https://github.com/volkov85/react-realtime-hooks
534
+
535
+ Run the local playground:
536
+
537
+ ```bash
538
+ npm run demo
539
+ ```
540
+
541
+ ## Contributing
542
+
543
+ Development and release workflow live in [CONTRIBUTING.md](./CONTRIBUTING.md).
544
+
545
+ ## License
546
+
547
+ MIT