Skip to content

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

npm install @microsoft/signalr
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

.withServerTimeoutInMilliseconds(60000)
.withKeepAliveIntervalInMilliseconds(30000)

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.HubConnection in 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

conn.on('Msg', handler);
// no cleanup → leak across re-renders

✅ Correct: cleanup in useEffect return

useEffect(() => {
    conn.on('Msg', handler);
    return () => conn.off('Msg', handler);
}, [conn]);

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.

Further reading