Realtime Cursor Tracking with .NET and React using SignalR
Realtime cursor tracking is a key feature in modern collaborative web apps, enhancing user interaction and experience. This series will guide you through creating production-ready cursor tracking using ASP.NET Core SignalR together with a React web app!
We will cover the following over a series of articles:
- Realtime Cursor Tracking with .NET and React using SignalR (You are here).
- Sync React with SignalR Events.
- Improve Signalr and React Performance User Experience.
Check back for updates to this series!
Why SignalR?
SignalR enables real-time communication between the server and clients. The traditional client-server model is only one-way: the client can send requests to the server, but the can't send information back on its own.
Over the years, there have been several ways for servers to communicate with clients:
- WebSockets: Provides a simultaneous two-way communication channel over a single TCP connection, allowing both client and server to communicate using WebSockets.
- Server-Sent Events: Uses a long-running HTTP connection to send messages from the server to the client.
- Long polling: Keeps a connection open until the server has a message to send, then reopens the connection.
While WebSockets are the gold standard for real-time communication, they may not always be supported. SignalR shines by implementing all three methods to allow for graceful fallback, without requiring us to write any extra code. Let’s start by enabling clients to broadcast their cursor position to all other clients.
Adding SignalR to a .NET Web Project.
SignalR is part of the ASP.NET Core Shared framework, so setup is straightforward with no need for additional libraries.
We begin by creating a Hub
. Hubs manage all the connections, groups, and messaging, allowing us to focus on client-server communication. Any method in a Hub
is like an API endpoint, except that it is called by a client using the SignalR connection instead of the HTTP protocol.
Since it is a two-way connection, we can use the Hub
to send messages back to clients using the SendAsync
method.
A method that will allow Clients to broadcast their position will look something like this:
_10public class CursorHub : Hub_10{_10 public async Task UpdateCursorPosition(string user, int x, int y)_10 {_10 await Clients.All.SendAsync("CursorPositionUpdated", user, x, y);_10 }_10}
Next, we have to register our CursorHub
and assign it an endpoint so we can connect with it:
_11// Program.cs_11_11var builder = WebApplication.CreateBuilder(args);_11_11builder.Services.AddSignalR(); // Add all services required by SignalR_11_11var app = builder.Build();_11_11app.MapHub<CursorHub>("/cursor"); // Map our hub endpoints and sockets to /cursor_11_11app.Run();
SignalR Type Safety
Any messages sent to clients are defined by a string, which can lead to typos. Instead of using SendAsync
, we can make our Hub
strongly typed with Hub<T>
, where T
is an interface containing client method abstractions.
In our case, clients can receive a CursorPositionUpdated
event that contains the username and, the x position and the y position. To type this we can create an interface called ICursorClient
like this:
_10public interface ICursorClient_10{_10 Task CursorPositionUpdated(string user, int x, int y);_10}
Now we can change our CursorHub
to this:
_10public class CursorHub : Hub<ICursorClient>_10{_10 public async Task UpdateCursorPosition(string user, int x, int y)_10 {_10 await Clients.All.CursorPositionUpdated(user, x, y);_10 }_10}
Cursor API Design
Currently, our UpdateCursorPosition
takes three parameters: a username, and the cursor's x and y positions. However, this does go against some important considerations we should make when designing the API surface of our CursorHub
.
When designing any API that is consumed by another application, we should always prepare for breaking changes. This design doesn't accommodate changes without breaking compatibility. SignalR servers and clients will encounter errors when invoking a method with an incorrect parameter count.
To avoid this, we should replace our three parameters with a single custom object. Since adding additional properties to our custom object doesn’t change the number of parameters, it won’t be a breaking change. The same goes for any outgoing messages.
Let’s update our Hub to reflect these best practices. While we’re at it, let’s introduce a cursor color as we will be needing it in the future:
_10// CursorModels.cs_10public record UpdateCursorMessage(string Color, string Name, int X, int Y);_10_10// CursorHub.cs_10public async Task UpdateCursorPosition(UpdateCursorMessage message)_10{_10 await Clients.All.CursorPositionUpdated(new {user, x, y});_10}
Updating Clients When Connecting or Disconnecting
Next, we'll address what happens when a client connects or disconnects from our Hub.
Currently, two issues arise:
- A newly connected client only receives updates about other connected clients when they move their mouse. They start in an empty application, regardless of how many others are connected.
- Disconnected clients still appear for other clients since there's no way to remove a cursor.
Both issues are easily fixed using SignalR’s OnConnectedAsync
and OnDisconnectedAsync
connection events. But before we can implement these, we should find a way to keep track of all connected clients.
State Management
Though SignalR manages all connections and the sending and receiving of messages, it does not maintain a list of all connected users or their groups, as noted in this issue.
Fortunately, keeping track of connected clients doesn't have to be complex. All SignalR connections are transient. If your backend crashes, all clients will need to reconnect anyway. So, we can keep our connected clients in-memory without any drawbacks
We'll use ConcurrentDictionary
, expecting multiple hubs to access the dictionary simultaneously. We'll use the dictionary because each connection should be unique, and the ConnectionID
helps avoid duplicate data.
It is important to note that SignalR Hubs are transient, so we shouldn’t keep any state in them. We could add a static variable to the Hub, but separating concerns and keeping state and logic separate is better.
Something like this InMemoryDataStore
should suffice, but remember to register it as a singleton in your DI container:
_10public class InMemoryDataStore : IDataStore_10{_10 public ConcurrentDictionary<string, Cursor> Mice { get; set; } = [];_10}_10_10public interface IDataStore_10{_10 ConcurrentDictionary<string, Cursor> Mice { get; set; }_10}
Connection Events
With state management sorted, we can update all clients when another client joins or leaves the session. We can do this by overriding the OnConnectedAsync
and OnDisconnectedAsync
methods, respectively.
We want the following to happen:
- When connecting:
- Create a new Cursor object that contains all required information (like the x and y positions).
- Store the object in our datastore.
- Notify all other clients that a new client has connected.
- Supply a list of already connected clients to the newly connected client.
- When disconnecting:
- Remove the object from our datastore.
- Notify all other clients that a client has disconnected.
When implemented, it should look something like this:
_48public class CursorHub(IDataStore dataStore) : Hub<ICursorClient>_48{_48 // ... Other methods_48_48 public override async Task OnConnectedAsync()_48 {_48 var connectionId = Context.ConnectionId;_48 // We will revisit this part in a future post,_48 // but for now let's use Bogus to create some fake user data!_48 var faker = new Faker();_48 dataStore.Mice[connectionId] = new Cursor_48 {_48 ConnectionId = connectionId,_48 Color = faker.Commerce.Color(),_48 Name = faker.Name.FirstName(),_48 X = 0,_48 Y = 0_48 };_48_48 var currentCursor = dataStore.Mice[connectionId];_48 var otherCursors = dataStore.Mice.Values_48 .Where(m => m.ConnectionId != connectionId).ToList();_48_48 // SignalR also has a 'Clients.Others' property,_48 // but that doesn't work in 'OnConnectedAsync'_48 await Clients.AllExcept(connectionId).CursorConnected(currentCursor);_48 await Clients.Caller.ConnectionEstablished(otherCursors);_48_48 await base.OnConnectedAsync();_48 }_48_48 public override async Task OnDisconnectedAsync(Exception? exception)_48 {_48 var connectionId = Context.ConnectionId;_48 dataStore.Mice.Remove(connectionId, out _);_48 await Clients.All.CursorDisconnected(connectionId);_48 await base.OnDisconnectedAsync(exception);_48 }_48}_48_48// ICursorClient.cs_48public interface ICursorClient_48{_48 // ... Other client methods_48 Task CursorConnected(Cursor cursor);_48 Task CursorDisconnected(string connectionId);_48 Task ConnectionEstablished(List<Cursor> otherMice);_48}
Your SignalR setup now tracks connections and updates clients as users join or leave. In the next article, we'll connect this backend to a React frontend for real-time cursor tracking.
Quick Recap
In this article, I’ve shown how easy it is to get started with SignalR in ASP.NET Core. Next, we made our CursorHub
strongly typed by implementing the Hub<T>
and wired everything up to the ASP.NET WebApplication
.
With everything running, we added the basic requirements for broadcasting the cursor information, like keeping track of all connections and handling clients connecting and disconnecting from our service.
You now should have a functional SignalR setup ready for real-time cursor tracking. In the next article, we’ll explore the React side of our little project.
What to read next:
I really hope you enjoyed this article. If you did, you might want to check out some of these articles I've written on similar topics.
- Read Improve Signalr and React Performance User Experience— 4 minutes readImprove Signalr and React Performance User Experience
- Read Sync React with SignalR Events— 7 minutes readSync React with SignalR Events
- Read Three ways to structure .NET Minimal APIs— 9 minutes readThree ways to structure .NET Minimal APIs