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.
- 🌐 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.
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.
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.
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>;// 📥 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");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,
});@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).
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.
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.
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.
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.
| 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. |
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.
- 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.
Setup, scripts and workflow live in CONTRIBUTING.md.