SignalR JavaScript Client — Connecting, Reconnecting, and Handling Events
Use the @microsoft/signalr JavaScript client: connection lifecycle, automatic reconnection, invoking hub methods, handling disconnects, and the patterns for a resilient clinical dashboard frontend.
Installation
# npm
npm install @microsoft/signalr
# CDN (for non-module environments)
# <script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr/dist/browser/signalr.min.js"></script>Building a Connection
import * as signalR from "@microsoft/signalr";
const connection = new signalR.HubConnectionBuilder()
// Hub URL — relative or absolute
.withUrl("/hubs/clinical", {
// JWT token factory — called on initial connect and reconnect
accessTokenFactory: () => getAccessToken(),
// Transport preference (falls back automatically)
transport: signalR.HttpTransportType.WebSockets,
// Headers for long-polling fallback
headers: { "X-Custom-Header": "value" },
})
// Automatic reconnection with custom delays (ms)
.withAutomaticReconnect([0, 2000, 10000, 30000, null])
// null = stop retrying, positive numbers = wait that long between retries
// Log level — reduce in production
.configureLogging(signalR.LogLevel.Warning)
.build();Starting the Connection
async function startConnection(): Promise<void> {
try {
await connection.start();
console.log("Connected to clinical hub");
await onConnected();
} catch (err) {
console.error("Connection failed:", err);
// Manual retry if withAutomaticReconnect is not sufficient
setTimeout(startConnection, 5000);
}
}
async function onConnected(): Promise<void> {
// Subscribe to ward updates after connecting
await connection.invoke("SubscribeToWard", currentWardId);
}
startConnection();Registering Event Handlers
// Register BEFORE starting the connection
// Handlers registered after start() may miss early messages
// Hub sends PatientAdmitted → call this function
connection.on("PatientAdmitted", (patient: PatientAdmittedDto) => {
updatePatientList(patient);
showNotification(`${patient.name} admitted to ${patient.ward}`);
});
connection.on("DrugOrderStatusChanged", (status: DrugOrderStatusDto) => {
updateOrderStatus(status.orderId, status.newStatus);
});
connection.on("AlarmTriggered", (alarm: AlarmDto) => {
playAlertSound(alarm.severity);
showAlarmModal(alarm);
});
connection.on("WardCapacityUpdated", (capacity: WardCapacityDto) => {
updateCapacityDisplay(capacity.occupied, capacity.total);
});
// Remove a specific handler
const handler = (patient: PatientAdmittedDto) => updatePatientList(patient);
connection.on("PatientAdmitted", handler);
// Later:
connection.off("PatientAdmitted", handler);
// Remove all handlers for an event
connection.off("PatientAdmitted");Invoking Hub Methods
// invoke — call hub method, wait for return value
const capacity = await connection.invoke<WardCapacityDto>(
"GetWardCapacity", wardId);
console.log(`Beds: ${capacity.occupied}/${capacity.total}`);
// send — call hub method, do not wait for return value
await connection.send("AcknowledgeAlarm", alarmId);
// invoke with error handling
try {
await connection.invoke("OrderMedication", patientId, drugName);
} catch (err: unknown) {
if (err instanceof Error) {
// HubException messages are forwarded here
showError(`Medication order failed: ${err.message}`);
}
}Reconnection Lifecycle
// Called while attempting to reconnect
connection.onreconnecting((error) => {
console.warn("Connection lost, reconnecting...", error);
updateConnectionStatus("reconnecting");
disableInteractiveControls();
});
// Called when reconnect succeeds
connection.onreconnected(async (connectionId) => {
console.log("Reconnected:", connectionId);
updateConnectionStatus("connected");
enableInteractiveControls();
// Critical: re-join groups — group membership is per-connection
await connection.invoke("SubscribeToWard", currentWardId);
await connection.invoke("SubscribeToAlerts", subscribedAlerts);
// Reload any state that may have changed during disconnect
await refreshWardState();
});
// Called when reconnect gives up (after all retry intervals exhausted)
connection.onclose((error) => {
console.error("Connection permanently closed:", error);
updateConnectionStatus("disconnected");
showPermanentDisconnectWarning();
// Optionally: full page reload, or manual "reconnect" button
});Streaming from the Server
// Receive a stream of data from the hub
const stream = connection.stream<VitalSignDto>("StreamVitalSigns", patientId);
const subscription = stream.subscribe({
next: (vital: VitalSignDto) => updateVitalsDisplay(vital),
error: (err: Error) => {
console.error("Vitals stream error:", err);
showStreamError();
},
complete: () => {
console.log("Vitals stream completed");
},
});
// Stop the stream when the component unmounts
function cleanup(): void {
subscription.dispose(); // cancels the CancellationToken on the server
}Sending a Stream to the Server
import { Subject } from "@microsoft/signalr";
// Client streams data to the server
const subject = new Subject<ObservationDto>();
await connection.send("UploadObservations", subject);
// Send observations
for (const obs of observations) {
subject.next(obs);
await delay(100);
}
subject.complete(); // signals end of stream to serverTypeScript Types for Hub Events
// Match these to your server-side DTOs
interface PatientAdmittedDto {
id: string;
name: string;
mrn: string;
ward: string;
admittedAt: string; // ISO 8601 datetime
}
interface DrugOrderStatusDto {
orderId: string;
newStatus: "Pending" | "Dispensing" | "Dispensed" | "Cancelled";
updatedAt: string;
updatedBy: string;
}
interface AlarmDto {
id: string;
type: string;
severity: "Low" | "Medium" | "High" | "Critical";
message: string;
wardId: string;
firedAt: string;
}React Integration Pattern
// Custom hook for ward monitoring
function useWardMonitor(wardId: string) {
const [patients, setPatients] = useState<PatientAdmittedDto[]>([]);
const [capacity, setCapacity] = useState<WardCapacityDto | null>(null);
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected">("connecting");
const connectionRef = useRef<signalR.HubConnection | null>(null);
useEffect(() => {
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/clinical", { accessTokenFactory: getAccessToken })
.withAutomaticReconnect()
.build();
connection.on("PatientAdmitted", p => setPatients(prev => [...prev, p]));
connection.on("WardCapacityUpdated", c => setCapacity(c));
connection.onreconnected(async () => {
await connection.invoke("SubscribeToWard", wardId);
});
connection.start().then(async () => {
setStatus("connected");
await connection.invoke("SubscribeToWard", wardId);
});
connectionRef.current = connection;
return () => { connection.stop(); };
}, [wardId]);
return { patients, capacity, status };
}Production issue I've seen: A clinical dashboard's React component registered
connection.on("PatientAdmitted", ...)inside auseEffectwithout cleanup. When the ward changed, the old handler was never removed. After navigating between wards 5 times, 5 duplicate handlers fired per event — the same patient appeared 5 times in the list per admission. Alwaysconnection.off(...)orconnection.stop()in theuseEffectcleanup.
Key Takeaway
Register event handlers BEFORE calling
connection.start(). Always handleonreconnecting,onreconnected, andonclose— re-join groups inonreconnected. Useinvokefor hub methods that return a value;sendfor fire-and-forget calls. Dispose stream subscriptions to cancel server-sideCancellationToken. In React, always clean up the connection inuseEffectcleanup to prevent handler accumulation.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.