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.
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;