Skip to main content

Using .NET SignalR in React: Real-Time Notifications and Data Streaming

00:03:48:53

Introduction

Real-time features are no longer "nice to have." Notifications, live tables, chat screens, dashboard metrics… At the core of all these, there's often a push mechanism like SignalR.

In this article, we'll build a step-by-step setup for listening to events published by ASP.NET Core SignalR on the React side, covering connection management, token usage, reconnect strategy, and a practical example structure.

Scenario: Backend Pushes, React Listens

Backend (.NET): Publishes events to clients via a Hub. Frontend (React): Connects to the Hub, defines event handlers, and updates state with incoming messages.

This architecture can be used in the following use cases:

  • Notification systems
  • Live order/transaction lists
  • Dashboard real-time metrics
  • "Processing…" status updates from the backend
  • Chat / support

Required Package on the React Side

The SignalR client is provided by Microsoft:

bash
npm i @microsoft/signalr

Manage SignalR Connection from a Single Place

The most common mistake in React: creating a new connection every time a component renders. To prevent this, producing the connection from a single factory/service is a good practice.

signalrClient.ts

tsx
import * as signalR from '@microsoft/signalr';

type CreateConnectionOptions = {
  baseUrl: string;
  hubPath: string;
  accessTokenFactory?: () => string | Promise<string>;
};

export function createSignalRConnection(options: CreateConnectionOptions) {
  const connection = new signalR.HubConnectionBuilder()
    .withUrl(`${options.baseUrl}${options.hubPath}`, {
      accessTokenFactory: options.accessTokenFactory,
      withCredentials: true,
    })
    .withAutomaticReconnect([0, 2000, 5000, 10000]) // 0ms, 2s, 5s, 10s
    .configureLogging(signalR.LogLevel.Information)
    .build();

  return connection;
}

withAutomaticReconnect() automatically recovers when the connection drops. The values in the array are the retry intervals.

Custom Hook: Connect / Listen / Cleanup

Now let's manage SignalR on the React side "the React way": connect with useEffect, subscribe to events, and clean up on unmount.

useSignalR.ts

tsx
import { useEffect, useRef, useState } from 'react';
import * as signalR from '@microsoft/signalr';
import { createSignalRConnection } from './signalrClient';

type UseSignalROptions = {
  baseUrl: string;
  hubPath: string;
  accessToken?: string;
};

export function useSignalR({ baseUrl, hubPath, accessToken }: UseSignalROptions) {
  const connectionRef = useRef<signalR.HubConnection | null>(null);
  const [status, setStatus] = useState<
    'disconnected' | 'connecting' | 'connected' | 'reconnecting'
  >('disconnected');

  useEffect(() => {
    const connection = createSignalRConnection({
      baseUrl,
      hubPath,
      accessTokenFactory: accessToken ? () => accessToken : undefined,
    });

    connectionRef.current = connection;

    connection.onreconnecting(() => setStatus('reconnecting'));
    connection.onreconnected(() => setStatus('connected'));
    connection.onclose(() => setStatus('disconnected'));

    let cancelled = false;

    async function start() {
      try {
        setStatus('connecting');
        await connection.start();
        if (!cancelled) setStatus('connected');
      } catch (err) {
        if (!cancelled) setStatus('disconnected');
        setTimeout(() => {
          if (!cancelled) start();
        }, 3000);
      }
    }

    start();

    return () => {
      cancelled = true;
      connection.stop().catch(() => undefined);
    };
  }, [baseUrl, hubPath, accessToken]);

  return { connection: connectionRef.current, status };
}

Listening to Events: A Notification Example

Let's assume the backend publishes the following event:

  • Event name: NotificationReceived
  • Payload: { id, title, message, createdAt }

NotificationsWidget.tsx

tsx
import { useEffect, useState } from 'react';
import { useSignalR } from './useSignalR';

type NotificationDto = {
  id: string;
  title: string;
  message: string;
  createdAt: string;
};

export function NotificationsWidget() {
  const [items, setItems] = useState<NotificationDto[]>([]);
  const token = localStorage.getItem('access_token') || undefined;

  const { connection, status } = useSignalR({
    baseUrl: 'https://localhost:5001',
    hubPath: '/hubs/notifications',
    accessToken: token,
  });

  useEffect(() => {
    if (!connection) return;

    const handler = (payload: NotificationDto) => {
      setItems(prev => [payload, ...prev].slice(0, 20));
    };

    connection.on('NotificationReceived', handler);

    return () => {
      connection.off('NotificationReceived', handler);
    };
  }, [connection]);

  return (
    <div style={{ padding: 16, border: '1px solid #ddd', borderRadius: 12 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
        <h3>Notifications</h3>
        <span>{status}</span>
      </div>
      {items.length === 0 ? (
        <p>No notifications yet.</p>
      ) : (
        <ul>
          {items.map(n => (
            <li key={n.id} style={{ marginBottom: 8 }}>
              <strong>{n.title}</strong>
              <div>{n.message}</div>
              <small>{new Date(n.createdAt).toLocaleString()}</small>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Cleaning up with off is important; otherwise, when the component remounts, the same event can fire twice.

Auth: Using JWT

The most robust approach:

  • React: Provide the token via accessTokenFactory
  • .NET: JWT bearer + SignalR hub auth configuration

On the React side, we already covered this above:

tsx
accessTokenFactory: () => token;

If the token expires, you can add a "refresh and return" logic inside accessTokenFactory.

CORS and Dev Environment Pitfalls

If React is running on a different port (and .NET on another):

  • You must configure CORS permissions correctly on the .NET side.
  • If CORS isn't set up properly for WebSocket/SSE transports, you'll get "connect failed" errors.

Practical tip: Protocol mismatches between http/https in dev can also break the connection. If React is on http and the backend on https, the browser may interfere. Use the same protocol for both.

Production Best Practices

Don't set up the connection separately on every page: An app-wide Provider/Singleton approach is better.

Route incoming events to a store: Managing them with a store like Redux/Zustand makes things more scalable.

Lower the LogLevel in production: Use LogLevel.Warning or turn it off entirely.

Clarify your reconnect strategy: In some systems, continuous reconnection means "wasted traffic."