reactjsreduxsignalrrtk-queryasp.net-core-signalr

RTKQuery with SignalR Websocket


I'm attempting to use RTK Query with a signalR websocket service to keep my react app up to date with server data.

My RTK API looks like this:

export const hubConnection = new signalR.HubConnectionBuilder()
    .withUrl("https://localhost:7152/messagehub", {
        withCredentials: false,
        skipNegotiation: true,
        logMessageContent: true,
        logger: signalR.LogLevel.Information,
        transport: signalR.HttpTransportType.WebSockets
    })
    .withAutomaticReconnect([0, 1000, 5000, 10000, 30000])
    .build();

export const apiSlice = createApi({
    reducerPath: 'apiSlice',
    baseQuery: fetchBaseQuery({ baseUrl: 'https://localhost:7152' }),
    endpoints: (builder) => ({
        // these are the websocket / signalR endpoints
        getNotifications: builder.query<NotificationType[], void>({
            query: () => 'messagehub',
            async onCacheEntryAdded(
                arg,
                { updateCachedData, cacheDataLoaded, cacheEntryRemoved },
            ) {
                console.log(hubConnection.state); // PRINTS Disconnected
                try {
                    // this loads the existing cache data
                    // await cacheDataLoaded; IF I UNCOMMENT THIS LINE, NOTHING FURTHER HAPPENS
                    if (hubConnection.state === signalR.HubConnectionState.Disconnected) {
                        console.log('SignalR connection is disconnected. Starting connection...');  // THIS MESSAGE PRINTS TO CONSOLE
                        await hubConnection.start().then(() => console.log("SignalR connection established")); // THIS MESSAGE PRINTS TO CONSOLE
                    }
                
                    hubConnection.on("ReceiveMessage", (notification: NotificationType) => {
                        console.log('StatusMessage', notification); // WITH THE cacheDataLoaded CALL COMMENTED OUT, THIS PRINTS WHEN A MESSAGE IS RECEIVED
                        updateCachedData((draft) => {
                            console.log('cache updating', draft); // THIS MESSAGE NEVER PRINTS
                            draft.push(notification) // THE STORE STAYS EMPTY
                        })
                    });
                } catch (error) {
                    console.error('Failed to connect to SignalR hub:', JSON.parse(JSON.stringify(error)));
                }
                // this is a promise that resolves when the socket is no longer being used
                await cacheEntryRemoved;
                // close signalR connection
                await hubConnection.stop();
            }
        }),
    }),
})

First, the await cacheDataLoaded method, if I leave it uncommented, blocks anything else from running. When I comment that out, I see an error on reload that says "Connection ID required", and while I know this is coming from SignalR, I don't know where / why.

In Redux devtools, the status of apiSlice.queries.getNotifications shows status: rejected. Again, I don't understand this message or where it's coming from-- there's nothing in logs / console showing any error in the code.

When I fire a test message from the signalR side, I see the message logged on the RTK Query side in the ReceiveMessage handler, but the updateCachedData method never fires. I wouldn't expect the ReceiveMessage handler to get fired at all if the getNotifications API was in a rejected status?

Anyone got signalR websockets running with RTK Query? I've seen plenty of examples using signalR as middleware, and I've been able to get signalR running in a single component-- but I'm hoping to leverage the RTK Query side of things and so far I stumped.


Solution

  • Using below code can fix the issue. Here are my improvements and comparison with the previous code.

    Issue Original Code Flaw Revised Code Improvement
    Invalid HTTP Request Used query: () => 'messagehub' to send HTTP GET to SignalR Hub endpoint. Replaced with queryFn: () => ({ data: [] }) to skip HTTP requests and avoid rejection.
    SignalR Instance Management Exported a direct hubConnection instance, risking multiple connections. Implemented singleton pattern via getHubConnection() to ensure a single global instance.
    cacheDataLoaded Blocking Unhandled await cacheDataLoaded exception disrupted SignalR connection logic. Added try/catch to safely handle cacheDataLoaded errors, allowing SignalR to proceed.
    Connection Lifecycle No cleanup for event listeners or connection termination on component unmount. Added hubConnection.off("ReceiveMessage") and hubConnection.stop() on cacheEntryRemoved.
    Cache Update Reliability Mutated draft directly (draft.push(...)), risking Immer update detection issues. Used immutable update (return [...draft, notification]) to ensure Immer tracks changes.
    Error Handling Partial try/catch coverage left critical operations unhandled. Unified error handling around SignalR connection, message listeners, and teardown logic.
    // signalr.ts
    import * as signalR from '@microsoft/signalr';
    
    let hubConnection: signalR.HubConnection;
    
    export const getHubConnection = () => {
        if (!hubConnection) {
            hubConnection = new signalR.HubConnectionBuilder()
                .withUrl("https://localhost:7152/messagehub", {
                    skipNegotiation: true,
                    transport: signalR.HttpTransportType.WebSockets
                })
                .withAutomaticReconnect([0, 1000, 5000, 10000, 30000])
                .build();
        }
        return hubConnection;
    };
    
    // apiSlice.ts
    import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
    import { getHubConnection } from './signalr';
    
    export const apiSlice = createApi({
        reducerPath: 'apiSlice',
        baseQuery: fetchBaseQuery({ baseUrl: 'https://localhost:7152' }),
        endpoints: (builder) => ({
            getNotifications: builder.query<NotificationType[], void>({
                queryFn: () => ({ data: [] }),
                async onCacheEntryAdded(
                    arg,
                    { updateCachedData, cacheDataLoaded, cacheEntryRemoved },
                ) {
                    const hubConnection = getHubConnection();
                    try {
                        await cacheDataLoaded;
                    } catch (error) {
                        console.log(error);
                    }
    
                    try {
                        if (hubConnection.state === signalR.HubConnectionState.Disconnected) {
                            await hubConnection.start();
                        }
    
                        hubConnection.on("ReceiveMessage", (notification: NotificationType) => {
                            updateCachedData((draft) => {
                                return [...draft, notification];
                            });
                        });
    
                        await cacheEntryRemoved;
                        hubConnection.off("ReceiveMessage");
                        await hubConnection.stop();
                    } catch (error) {
                        console.error('SignalR error message:', error);
                    }
                }
            }),
        }),
    });
    
    export const { useGetNotificationsQuery } = apiSlice;