SignalR from React
Key Points
@microsoft/signalr— official JS client. WebSocket (preferred) → SSE → Long Polling fallback.- Auto-reconnect with exponential backoff. Subscribe to lifecycle events.
- Auth: pass token via
accessTokenFactory; for cookie-auth (BFF), connection auto-attaches. - React patterns: connection lives in a context / custom hook; cleanup on unmount; React 18 strict mode double-mount issues require care.
- Strongly typed: shared TS interfaces or codegen from backend hub definitions.
Concepts (deep dive)
Setup
import * as signalR from '@microsoft/signalr';
const conn = new signalR.HubConnectionBuilder()
.withUrl('/hubs/chat', { accessTokenFactory: () => localStorage.getItem('token') ?? '' })
.withAutomaticReconnect([0, 2000, 10000, 30000])
.configureLogging(signalR.LogLevel.Information)
.build();
conn.on('ReceiveMessage', (user: string, msg: string) => console.log(user, msg));
await conn.start();
await conn.invoke('SendMessage', 'Alice', 'Hi');
React hook
import { useEffect, useState, useRef } from 'react';
function useSignalR(hubUrl: string) {
const [conn, setConn] = useState<signalR.HubConnection | null>(null);
const initialized = useRef(false);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
const c = new signalR.HubConnectionBuilder()
.withUrl(hubUrl)
.withAutomaticReconnect()
.build();
c.start().then(() => setConn(c)).catch(console.error);
return () => {
c.stop();
};
}, [hubUrl]);
return conn;
}
initialized.current guards against React 18 strict-mode double mount.
React context + provider
const SignalRContext = createContext<signalR.HubConnection | null>(null);
export function SignalRProvider({ children }: { children: ReactNode }) {
const conn = useSignalR('/hubs/chat');
return <SignalRContext.Provider value={conn}>{children}</SignalRContext.Provider>;
}
export function useChat() {
return useContext(SignalRContext);
}
function ChatPanel() {
const conn = useChat();
const [messages, setMessages] = useState<string[]>([]);
useEffect(() => {
if (!conn) return;
const handler = (msg: string) => setMessages(m => [...m, msg]);
conn.on('ReceiveMessage', handler);
return () => conn.off('ReceiveMessage', handler);
}, [conn]);
// ...
}
Auth: BFF vs token
// Token (direct)
.withUrl('/hub', { accessTokenFactory: () => getToken() })
// Cookie (BFF) — automatic
.withUrl('/hub') // cookie sent on connect upgrade
For browsers + WS, cookie-auth is cleanest (BFF pattern).
Reconnection events
conn.onreconnecting(err => console.log('Reconnecting', err));
conn.onreconnected(connectionId => console.log('Reconnected', connectionId));
conn.onclose(err => console.log('Closed', err));
Server method signatures
public class ChatHub : Hub<IChatClient>
{
public Task SendMessage(string user, string msg) => Clients.All.ReceiveMessage(user, msg);
public IAsyncEnumerable<int> Counter() => /* stream */;
}
public interface IChatClient { Task ReceiveMessage(string user, string msg); }
Client invokes SendMessage; server pushes ReceiveMessage.
Streaming
conn.stream('Counter').subscribe({
next: value => console.log(value),
complete: () => console.log('done'),
error: err => console.error(err)
});
MessagePack protocol
import { MessagePackHubProtocol } from '@microsoft/signalr-protocol-msgpack';
const conn = new signalR.HubConnectionBuilder()
.withUrl('/hub')
.withHubProtocol(new MessagePackHubProtocol())
.build();
Smaller messages; faster.
Strongly typed (manual)
interface ChatHub {
SendMessage(user: string, msg: string): Promise<void>;
ReceiveMessage(user: string, msg: string): void;
}
const conn = ...;
const hub = conn as unknown as { invoke: <K extends keyof ChatHub>(method: K, ...args: Parameters<ChatHub[K]>) => Promise<ReturnType<ChatHub[K]>> };
Or codegen from C# hub interface to TS. Tools like TypedSignalR.Client.TypeScript.
Keep alive
Network conditions
WebSockets through corporate proxies sometimes blocked → SignalR falls back to SSE / long-polling. Slow but works.
Disconnect handling UX
function StatusIndicator() {
const [status, setStatus] = useState<'connected' | 'reconnecting' | 'disconnected'>('connected');
// wire to onreconnecting / onreconnected / onclose
}
Show user when connection drops.
React 18 strict mode
In dev, components mount twice. Without ref guard, two SignalR connections per mount:
const initialized = useRef(false);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
/* setup */
}, []);
Testing
- Mock
signalR.HubConnectionin unit tests. - E2E tests: real connection against test server (WebApplicationFactory).
Code: correct vs wrong
❌ Wrong: connection per component
Every <Chat /> mount creates a new connection.
✅ Correct: shared via context
One connection in provider; children use it.
❌ Wrong: handler not removed on unmount
✅ Correct: cleanup in useEffect return
Design patterns for this topic
Pattern 1 — "Context provider for connection"
- Intent: single connection per app.
Pattern 2 — "Custom hook for events"
- Intent: clean useEffect / cleanup.
Pattern 3 — "MessagePack protocol"
- Intent: smaller payloads.
Pattern 4 — "Status indicator UX"
- Intent: disconnect feedback.
Pattern 5 — "Strict mode ref guard"
- Intent: avoid double connection.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Cookie auth | Simple | CSRF; cross-origin pain |
| Token auth | Cross-origin OK | Token mgmt |
| MessagePack | Smaller | Less debuggable |
| Auto reconnect | Resilience | Need lifecycle events |
When to use / when to avoid
- Use for chat, dashboards, real-time updates.
- Use context provider for shared connection.
- Avoid per-component connections.
- Avoid without ref guard in strict mode.
Interview Q&A
Q1. Auth on SignalR JS? accessTokenFactory for bearer; cookie auto via BFF.
Q2. React 18 strict mode pitfall? Effects run twice in dev. Ref guard or fact-check connection state.
Q3. Lifecycle events? onreconnecting, onreconnected, onclose.
Q4. MessagePack vs JSON? MessagePack: smaller, faster. JSON: debug-friendly.
Q5. Streaming from React? conn.stream('M').subscribe({ next, complete, error }).
Q6. Connection per component bad why? Each component creates a new connection → server load + duplicate events.
Q7. Strongly-typed clients? Manual TS interface or codegen (TypedSignalR.Client.TypeScript).
Q8. Reconnection delays? withAutomaticReconnect([0, 2000, 10000, 30000]) — exponential.
Q9. SignalR over corporate proxy? WS may be blocked; falls back to SSE / long polling.
Q10. Testing? Mock HubConnection unit; E2E with real test server.
Q11. Disconnect UX? Status indicator subscribed to lifecycle events.
Q12. Cleanup pattern? useEffect return → conn.off / stop.
Gotchas / common mistakes
- ⚠️ Connection per component.
- ⚠️ No cleanup in useEffect.
- ⚠️ Strict mode double mount unguarded.
- ⚠️ Token in URL for WS — fine but exposes in logs.
- ⚠️ No reconnect feedback UI.