signalr and some hooks
By Per Fröjd
- javascript
- react
- signalr
- snippets
SignalR and Websockets
To keep things short, because this post isn’t intended to explain the concept of websockets, I recently came across websockets and specifically SignalR in a recent project at work.
The use case was fairly simple, during the session of an authenticated user, now and then events would be pushed from the backend which needed to be presented in the frontend. SignalR comes with its own JS-client, but at the time of implementing, I couldn’t find any neat React wrappers, so I decided to test things out myself.
A first use case
First of all, SignalR is event-based, which means that we’ll essentially be working with EventEmitter-based implementations. It means we register listeners on certain events, which trigger side effects, so lets get the basics set up first.
For this, we’re using @microsoft/signalr
.
let _connection
function connect(url) {
if (_connection) {
return _connection
}
_connection = new signalR.HubConnectionBuilder()
.withUrl(url)
.configureLogging(signalR.LogLevel.Debug)
.build()
}
function handleOnClose(error) {
console.log('Internal connection closed')
}
function handleReconnecting(error) {
console.log('Attempting to reconnect')
}
function handleReconnected(id) {
console.log('Successfully reconnected')
}
export async function initializeSignalR() {
try {
let groupName = await APIClient.subscribeToVehiclePositions()
if (!groupName) {
return
}
connect(`api/live/vehicle?groupName=${groupName}`)
_connection.onclose(handleOnClose)
_connection.onreconnected(handleReconnected)
_connection.onreconnecting(handleReconnecting)
await _connection
.start({ transport: ['webSockets', 'serverSentEvents', 'longPolling'] })
.catch((e) => {
console.error(e)
console.log('Could not start connection')
})
console.log('Successfully connected')
return _connection
} catch (e) {
console.error(e)
console.log('Could not initialize live positions')
}
}
So this is a bit of a mouthful, but lets walk through it one by one. First of all, worth mentioning, is keeping the _connection
object outside of the initializeSignalR
method. This makes it easier to deal with scoping, and if you intend to extend this file with additional methods. This file could have the specific implementations as well, and not just the bootstrapping of the connection.
handleOnClose
, handleReconnecting
, handleReconnected
are mostly used for debugging in this case, but could be useful for displaying warnings/notifications based on the connection, in case it’s been lost.
Another thing worth mentioning, for the first use case, events are being published to certain channels depending on whether the currently logged in user belongs to department A, or department B. This is mainly done on the APIClient.subscribeToVehiclePositions
call, which returns the name of the channel we want to subscribe on.
Without going too deep into the implementation, it works something like this:
- Authenticated user communicates with backend, saying I want to subscribe to this channel, which is an REST-endpoint on the backend. Exactly what happens in the backend is outside of the scope of this, but it returns a channelname for the user.
- SignalR then needs to be pointed towards a SignalR channel, which also acts very much like an endpoint.
- Using the
signalR.HubConnectionBuilder
, we point it to the channel, with the (in this case)groupName
parameter.
Once the connection has been built, we need to start it.
await _connection
.start({ transport: ['webSockets', 'serverSentEvents', 'longPolling'] })
.catch((e) => {
console.error(e)
console.log('Could not start connection')
})
I had to experiment a bit with the different transports, mainly due to CORS, but found that this order of transports worked the best.
Okay, so where are we at now? We have a working connection, we have some callbacks to handle certain connection-related events, but we still have no event-listeners here. So lets add them!
const messages = {
RECEIVE_LIVE_POSITION: 'ReceiveLivePosition',
SUBSCRIBED_TO_GROUP: 'SubscribedToGroup',
}
function listenToEvents() {
_connection.on(messages.SUBSCRIBED_TO_GROUP),
(data) => {
// We've received the subscription event.
}
_connection.on(messages.RECEIVE_LIVE_POSITION),
(data) => {
// We've received a position event.
}
}
function handleOnClose(error) {
// .. other code
_connection.off(messages.RECEIVE_LIVE_POSITION)
_connection.off(messages.SUBSCRIBED_TO_GROUP)
// .. other code
}
export async function initializeSignalR() {
// .. other code
listenToEvents()
// .. other code
}
Two things worth mentioning here, first of all we add a new function to initiate the event listeners. This can be registered at any point after the _connection
has been instantiated.
The other thing is adding the .off
calls in the handleOnClose
method. This is more of a “in-case” thing, because I think the listeners are supposed to clean up themselves on closing a connection.
Next step will be to start dealing with the data received by the events.
function parseMessage(msg) {
try {
return JSON.parse(msg)
} catch (e) {
// Couldn't parse message,
console.error(e)
return null
}
}
function listenToEvents() {
_connection.on(messages.SUBSCRIBED_TO_GROUP),
(data) => {
// We've received the subscription event.
let message = parseMessage(data)
if (!message) {
console.log('Invalid data')
}
// Available to do whatever is required at this point.
}
_connection.on(messages.RECEIVE_LIVE_POSITION),
(data) => {
// We've received a position event.
}
}
All data received via the .on
event-listeners is JSON-stringified, so we’ll need to parse it ourselves in order to use it. At this point, it’s up to you to figure out what you want to do with it.
In the use case above we had a mix of EventEmitter
and redux
which simplified a lot of the event handling.
So what about React
Lets take this imaginary component here:
const EventLog = () => {
return (
<div className="container-fluid">
<div className="row">
<div className="col-md-12">
<table className="table table-striped">
<thead>
<tr>
<th>Time (UTC)</th>
<th>Type</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{messages.map((m) => {
return (
<tr key={`${m.timestamp}`}>
<td>{m.timestamp}</td>
<td>{m.type}</td>
<td>{m.message}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)
}
It’s a bit of a mess, but it’s essentially a plain table within some bootstrap containers. It will iterate through a list of messages, and display some keys for it. Lets see if we can reuse some of the stuff we built earlier.
const EventLog = () => {
const [messages, setMessages] = useState([]);
const [connection, setConnection] = useState(null);
useEffect(() => {
let _connection = initializeSignalR();
setConnection(_connection);
return async function () {
conn.then(c => {
c.stop().then(() => {
// Do nothing
});
});
};
}, []);
useEffect(() => {
// Add listeners here to the different event types that you define in the backend.
connection.on(liveMessageTypes.RECEIVE_LIVE_NOTIFICATION, data => {
let notification = parseMessage(data);
// This should probably be refined, maybe build a quick list of x messages, and clean out
// the older when a new one comes, so it doesn't grow to infinity.
setMessages(messages.concat([notification]));
});
connection.on(liveMessageTypes.SUBSCRIBED_TO_GROUP, () => {
// Do nothing, this is only here to prevent a stupid warning from signalR.
});
return function () {
if (connection) {
connection.off(liveMessageTypes.RECEIVE_LIVE_NOTIFICATION);
connection.off(liveMessageTypes.SUBSCRIBED_TO_GROUP);
}
};
}, [connection, messages]);
return (
// tables
);
};
So lets walk through the hooks that we’ve implemented here, the first hook is a useEffect
that attempts to set up connection. It’s intentionally set the dependency-array to be empty, to make sure that this effect only runs once. The second hook is responsible for setting up the event listeners. I found it easiest to split the effects into two different hooks (and that’s encouraged as far as I understand it).
In both of the effects, we make sure to register a return function, this function will be executed upon the component unmounting, and makes sure that we’re not bleeding events after the page has been disposed.
Lastly, we set up a quick local state to contain the messages received, and whenever we use the setMessages
call, we concat the current array of messages with the new one. As the comment mentions, it’s probably good to set up some sort of limit on how large the list can be, and dispose of the older messages.
Now when I think about it, the connection useState
can probably be removed and switched out for a useRef
call instead, since updating it shouldn’t really cause a re-render (nor does it need to).
Conclusion
So that’s it, these are the building blocks of integrating SignalR into your application. It’s definitely not fool-proof, nor plug and play, but a start to something.