npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dammers/use-signalr

v0.3.0

Published

Fully-typed multi-hub SignalR provider + hooks for React: auto-reconnect, invoke retry, lazy hubs, live status.

Readme

🔌 @dammers/use-signalr

Fully-typed, reusable SignalR provider + hooks for React — driven entirely by your contract.

npm types license

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, no declare 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 optional enabled); 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/signalr

Peer 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:

  • useSignalRInvoke aborts in-flight calls on unmount — a leave issued in cleanup can be cancelled before it reaches the server.
  • useSignalRSend drops silently if the hub isn't Connected — 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 useSignalRInvoke for 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, use useSignalRTeardown.

📚 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, or connectionKey change. Token rotation alone does not rebuild — accessTokenFactory is re-read on every negotiate.
  • accessTokenFactory and the on* 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.

📄 License

MIT © DammersCode