@dammers/use-signalr
v0.3.0
Published
Fully-typed multi-hub SignalR provider + hooks for React: auto-reconnect, invoke retry, lazy hubs, live status.
Maintainers
Readme
🔌 @dammers/use-signalr
Fully-typed, reusable SignalR provider + hooks for React — driven entirely by your contract.
const { SignalRProvider, useSignalRInvoke } = createSignalRClient({
hubs: {
"/hubs/chat": {
methods: { SendMessage: method<[roomId: string, message: string]>() },
},
},
});One factory call gives you a provider and a set of hooks, every one of them typed against your hub contract — inferred straight from the config, no separately hand-written contract type. Event args, method args and return values are all inferred.
✨ Features
- 🌐 Many hubs, one provider. Manage any number of hubs side by side — each gets its own connection, status, config and lifecycle. List them as keys; the hooks take the hub you want.
- 🧠 Fully typed, contract inferred from config. Declare each hub's events/methods once with
event()/method()— no hand-written contract type, nodeclare module, no globals. Event args, method args and return values are all inferred. - 🔇 No "No client method found" warnings, by construction. Every event you declare is automatically pre-bound to a no-op handler at connection build time — nothing to opt into, nothing that can drift from the contract.
- ⚙️ Per-hub & global config. Set defaults once, override anything per hub: reconnect strategy, retries, transport, logging, lazy behavior.
- ♻️ Auto-reconnect. Built-in:
true, a custom delay array, or your own retry policy. Plus a connect-retry budget for the first connect. - 🔁 Invoke retry. Opt-in per call, idempotent-safe, with jittered backoff and smart retriable-vs-business-error detection.
- 💤 Lazy hubs. Connect on first use, disconnect (after a grace period) on last unmount. Ref-counted and StrictMode-safe.
- 🟢 Live per-hub status. Subscribe to a hub's connection state; components re-render only when that hub changes.
- 🔄 Reconnect hooks. Run a callback after a hub reconnects — e.g. refetch state that went stale.
- 🔑 Auth via props. Pass
baseUrl+accessTokenFactory(gate with the optionalenabled); the token is re-read on every negotiate, so rotation needs no rebuild. - 🪶 Zero runtime deps. Only peer deps:
react,react-dom,@microsoft/signalr.
📦 Install
npm i @dammers/use-signalr @microsoft/signalrPeer deps: react ≥ 19, react-dom ≥ 19, @microsoft/signalr ≥ 8 (tested against 8–10).
React 19 is required — the library uses the use hook and JSX context providers.
🚀 Usage
1. Define your contract and create the client
Your app contract isn't hand-written — it's inferred from the config. The
keys of config.hubs declare the hubs; each hub's events (what the
server pushes to you) and methods (what you invoke) are declared inline
using the event() and method() markers.
// signalr.ts
import { createSignalRClient, event, method } from "@dammers/use-signalr";
export const {
SignalRProvider,
useSignalR,
useSignalREffect,
useSignalRInvoke,
useSignalRSend,
useSignalRTeardown,
useHubStatus,
useOnReconnected,
useHubConsumer,
} = createSignalRClient({
hubs: {
"/hubs/chat": {
events: {
ReceiveMessage: event<[user: string, message: string]>(),
},
methods: {
SendMessage: method<[roomId: string, message: string]>(),
JoinRoom: method<[roomId: string], { success: boolean }>(),
},
// per-hub config also goes here (see "Per-hub config")
},
},
// global defaults (all optional):
// lazy: false, reconnect: true, maxConnectRetries: 2, logLevel: LogLevel.Information
});event<Args>() takes the handler's argument tuple; method<Args, Return>()
takes the argument tuple and the resolved return type (defaults to void if
omitted). Neither returns anything meaningful at runtime — they're phantom-typed
markers whose only job is to carry the types for inference. createSignalRClient
is called with no explicit generic: its type is inferred from the config
object you pass.
2. Mount the provider with your auth
The provider takes no hubs prop — it already knows them from the config.
import { SignalRProvider } from "./signalr";
<SignalRProvider
baseUrl={serverUrl} // e.g. "https://api.example.com"
accessTokenFactory={() => getAccessToken()} // sync or async; read on every (re)negotiate
enabled={isAuthenticated} // optional, default true; false -> stops + clears all connections
connectionKey={accessToken} // optional: forces reconnect when it changes (re-login)
onError={(hub, err) => toast.error(`Connection to ${hub} failed`)}
onStatusChange={(hub, status) => {
if (status === "reconnecting") toast.warning(`Reconnecting to ${hub}…`);
if (status === "reconnected") toast.success(`Reconnected to ${hub}`);
}}
>
<App />
</SignalRProvider>;3. Use the hooks — everything below is fully typed
// 📥 Listen to a server event — args inferred from the contract
useSignalREffect("/hubs/chat", "ReceiveMessage", (user, message) => {
console.log(user, message);
});
// 📤 Invoke a server method — args + return inferred, waits for connection
const sendMessage = useSignalRInvoke("/hubs/chat", "SendMessage");
await sendMessage(roomId, "hello"); // typed params, Promise<void>
// 🏹 Typed fire-and-forget — no connect-wait, dropped if the hub isn't connected.
// Stable across renders, so it's safe to capture in an unmount cleanup.
const send = useSignalRSend("/hubs/chat", "SendMessage");
await send(roomId, "bye"); // typed args; Promise<boolean> (true = dispatched)
// 🚪 Reliable teardown — for a method called in an effect cleanup. Survives
// unmount, queues if the hub is still connecting (instead of dropping), holds a
// lazy hub open until it flushes. Best-effort: Promise<boolean> (true = dispatched).
const leaveRoom = useSignalRTeardown("/hubs/chat", "LeaveRoomAsync");
useEffect(() => {
joinRoom(roomId);
return () => {
leaveRoom(roomId);
}; // lands even mid-connect or on unmount
}, [roomId, joinRoom, leaveRoom]);
// 🟢 Live connection status (re-renders only when THIS hub's status changes)
const status = useHubStatus("/hubs/chat"); // "connecting" | "connected" | "reconnecting" | ...
// 🔄 Re-sync after a reconnect (e.g. refetch a query)
useOnReconnected("/hubs/chat", () => refetchMessages());
// ⚓ Keep a lazy hub connected for this component's lifetime without subscribing
useHubConsumer("/hubs/chat");
// 🛠️ Last resort: the raw HubConnection (prefer the typed hooks above)
const { getConnection } = useSignalR();
getConnection("/hubs/chat")?.send("SendMessage", roomId, "bye");⚙️ Per-hub config
Each value in config.hubs overrides the global defaults for that hub, alongside its events/methods declarations:
createSignalRClient({
hubs: {
"/hubs/chat": {
events: { ReceiveMessage: event<[user: string, message: string]>() },
methods: { SendMessage: method<[roomId: string, message: string]>() },
},
"/hubs/presence": {
events: { UserOnline: event<[userId: string]>() },
lazy: true, // connect only when first used
graceMs: 5000, // wait 5s after last consumer before disconnect
reconnect: [0, 2000, 10000, 30000], // custom retry delays (ms)
maxConnectRetries: 5,
transport: HttpTransportType.WebSockets,
skipNegotiation: true,
},
},
lazy: false, // global default for all hubs
reconnect: true, // true | false | number[] | IRetryPolicy
maxConnectRetries: 2,
});🔇 No "No client method found" warnings — by construction
@microsoft/signalr logs a warning whenever the server pushes an event with
no registered handler — which happens for any event no mounted component
currently subscribes to via useSignalREffect. Every event you declare with
event() in a hub's config is automatically pre-bound to a no-op handler at
connection build time (before start()) — there's no separate opt-in list to
keep in sync, and nothing to forget: if it's in the contract, it's pre-bound.
Real handlers registered later via useSignalREffect (or connection.on)
still receive events normally — SignalR fans out to every registered handler.
This has no effect on connection lifecycle (lazy/eager behavior is unchanged).
💤 Lazy hubs
With lazy: true, a hub connects only when the first component using it mounts (any hook for that hub) and disconnects graceMs after the last one unmounts. Ref-counted and StrictMode-safe. Default is eager.
🔁 Invoke retry
useSignalRInvoke fails fast by default (retries: 0, rethrows the raw server error). Opt in only for idempotent methods — a retried invoke is at-least-once:
const undo = useSignalRInvoke("/hubs/flow", "UndoAsync", {
retries: 2, // retry RETRIABLE failures (transport drops, 5xx, timeouts)
timeout: 15000, // per-attempt deadline
backoff: [250, 1000, 3000], // or (attempt) => ms; capped 30s, jittered
});Business errors (a HubException thrown while still connected) are never retried.
🚪 send vs invoke vs teardown — which call to use
The three "call the server" hooks differ in how they wait, what they return, and what happens on unmount. Pick by intent:
| | useSignalRInvoke | useSignalRSend | useSignalRTeardown |
| ------------------------- | --------------------------------------- | ----------------------------------------------- | -------------------------------- |
| Waits for connection | yes (up to timeout) | no | yes (up to timeout) |
| Not connected yet | waits, then invokes | drops (resolves false) | queues, flushes on connect |
| Returns | the method's typed result | boolean (dispatched?) | boolean (dispatched?) |
| On unmount | aborts in-flight call¹ | unaffected (reads conn at call time) | survives (runs detached) |
| Holds a lazy hub open | while mounted | while mounted | until the flush completes |
| Use for | request/response you need the result of | high-frequency loss-OK signals (typing, cursor) | one-shot teardown that must land |
¹ Only a mid-backoff retry is actually cancelled; pass { keepAliveOnUnmount: true } to keep it alive.
Reliable join/leave (session pattern)
A common pattern: join a session on mount, leave it in the effect cleanup.
const joinRoom = useSignalRInvoke("/hubs/chat", "JoinRoomAsync");
const leaveRoom = useSignalRTeardown("/hubs/chat", "LeaveRoomAsync");
useEffect(() => {
joinRoom(roomId);
return () => {
leaveRoom(roomId);
};
}, [roomId, joinRoom, leaveRoom]);A plain useSignalRInvoke or useSignalRSend makes the leave unreliable:
useSignalRInvokeaborts in-flight calls on unmount — a leave issued in cleanup can be cancelled before it reaches the server.useSignalRSenddrops silently if the hub isn'tConnected— so a leave that races a still-connecting socket (StrictMode's first mount, fast route switches) is lost.
useSignalRTeardown fixes both. It:
- survives the calling component's unmount (runs detached, never aborted),
- queues while connecting — waits up to
timeout(default 10s) for the hub, then sends, instead of dropping, - holds a lazy hub open until the flush completes, even if the unmounting component was its last consumer.
It's best-effort fire-and-forget: resolves true once dispatched, false if the hub never connected in time; it never throws. Under StrictMode's mount→cleanup→mount, the intermediate teardown does land (then the remount re-runs setup) — so the server is never left in a stale joined state, at the cost of one extra round-trip.
Already use
useSignalRInvokefor your leave and only need it not to be aborted on unmount? Pass{ keepAliveOnUnmount: true }. That covers the abort half but not the still-connecting race — for that, useuseSignalRTeardown.
📚 API
| Export | What it does |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| createSignalRClient(config) | Returns the Provider + hooks, typed against the contract inferred from config. Config keys declare the hubs; no explicit generic needed. |
| event<Args>() | Declares a server-pushed event inside a hub's events; Args is the handler's argument tuple. |
| method<Args, Return?>() | Declares an invocable server method inside a hub's methods; Args is the argument tuple, Return the resolved return type (default void). |
| <SignalRProvider> | Builds/starts connections, retries, auto-reconnects, exposes them via context. No hubs prop. |
| useSignalREffect(hub, event, handler) | Subscribe to a server event for the component lifetime. |
| useSignalRInvoke(hub, method, opts?) | Typed request/response invoker; waits for the connection, returns the method's result. Optional retry/backoff/timeout; keepAliveOnUnmount to not abort on unmount. |
| useSignalRSend(hub, method) | Typed fire-and-forget sender; drops if not connected. For high-frequency loss-OK signals. Safe in unmount cleanups. |
| useSignalRTeardown(hub, method, opts?) | Reliable teardown sender for a method called in cleanup: survives unmount, queues while connecting (instead of dropping), holds a lazy hub open until flushed. |
| useHubStatus(hub) | Live connection status; re-renders only when that hub changes. |
| useOnReconnected(hub, cb) | Run cb after the hub reconnects (e.g. refetch). |
| useHubConsumer(hub) | Keep a lazy hub connected for the component's lifetime without subscribing. |
| useSignalR() | Last-resort raw context: getConnection, isHubConnected, getStatus. |
Provider props
baseUrl, accessTokenFactory (required); enabled (optional, default true), connectionKey, onStatusChange, onError (optional). Connection behavior (lazy, reconnect, maxConnectRetries, logLevel, per-hub overrides) lives in the config passed to createSignalRClient, not on the provider.
📝 Notes
- The provider rebuilds connections when
baseUrl,enabled, orconnectionKeychange. Token rotation alone does not rebuild —accessTokenFactoryis re-read on every negotiate. accessTokenFactoryand theon*callbacks are read through refs, so passing fresh closures each render is fine — no reconnect storm.
🤝 Contributing
Setup, scripts and workflow live in CONTRIBUTING.md.
