Clients, Groups and Users
Send to everyone, to a named group, or to one person on all their devices, and run code when a connection opens or closes.
await Groups.AddToGroupAsync(Context.ConnectionId, "room-7");One send to Clients.All reaches everybody. Real apps rarely want that. A chat message belongs to one room, and a failed-job notice belongs to one person.
Groups
A group is a named set of connections that your own code maintains. Groups.AddToGroupAsync(Context.ConnectionId, "room-7") puts the current connection in it, and Clients.Group("room-7").SendAsync(...) sends to its members. Context.ConnectionId is the id SignalR gave this one connection. Adding the same connection twice is safe and does nothing. Group names are case sensitive, and there is no way to ask how many members a group has.
Users
One person can hold several connections at once, say a laptop and a phone. Clients.User(id) reaches all of them. The id is SignalR's user identifier, which by default is the ClaimTypes.NameIdentifier claim on the signed-in user. A claim is one labelled fact the server holds about them. User identifiers are case sensitive too.
Connection lifetime
Override OnConnectedAsync to run code when a client arrives, and OnDisconnectedAsync(Exception? exception) when it leaves. exception is null when the client stopped deliberately and holds the failure when the network broke. You do not have to remove the connection from its groups there; SignalR does that for you.
Shorthand
Clients.Others is shorthand for Clients.AllExcept(new[] { Context.ConnectionId }), and Clients.OthersInGroup(room) for Clients.GroupExcept(room, new[] { Context.ConnectionId }). Same sets of connections, fewer characters.
Example
using Microsoft.AspNetCore.SignalR;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
var app = builder.Build();
app.MapHub<RoomHub>("/hubs/room");
app.Run();
public class RoomHub : Hub
{
// Runs once per connection, before that client can call anything else.
public override async Task OnConnectedAsync()
{
await Groups.AddToGroupAsync(Context.ConnectionId, "lobby");
await base.OnConnectedAsync();
}
public async Task JoinRoom(string room)
{
await Groups.AddToGroupAsync(Context.ConnectionId, room);
// Everyone already in the room, but not the caller.
await Clients.OthersInGroup(room).SendAsync("Joined", Context.ConnectionId);
}
public Task SendToRoom(string room, string text)
=> Clients.Group(room).SendAsync("Receive", text);
// exception is null for a clean stop, set when the connection failed.
public override Task OnDisconnectedAsync(Exception? exception)
=> base.OnDisconnectedAsync(exception);
}When to use it
- Deliver a chat message only to the connections currently viewing that room.
- Show a "someone is typing" notice to everyone in the room except the person typing.
- Notify one person on both their laptop and their phone that a job they queued failed.
- Put every new connection into a per-tenant group so a tenant-wide notice cannot leak.
More examples
Rejoining a group after a reconnect
A dropped connection comes back with a new connection id and no group memberships, so the client must say again which room it wants. Carrying that in the query string lets the server rebuild the membership without a round trip. GetHttpContext returns null when the connection did not come over HTTP, which is why the null-conditional operator is there.
public class RoomHub : Hub
{
public override async Task OnConnectedAsync()
{
// Read the original HTTP request: /hubs/room?room=general
string? room = Context.GetHttpContext()?.Request.Query["room"].ToString();
if (!string.IsNullOrEmpty(room))
{
await Groups.AddToGroupAsync(Context.ConnectionId, room);
await Clients.Group(room).SendAsync("Joined", Context.ConnectionId);
}
await base.OnConnectedAsync();
}
}Shorthand and longhand for excluding the caller
Shown as pairs so either form is recognisable in someone else's hub. The longhand earns its place when you have more than one connection to leave out, for example skipping both the caller and a connection that has muted the room. The shorthand only ever excludes the caller.
public class RoomHub : Hub
{
public async Task Typing(string room)
{
// Shorthand: everyone in the room but me.
await Clients.OthersInGroup(room)
.SendAsync("Typing", Context.ConnectionId);
// The same set of connections, named explicitly.
await Clients.GroupExcept(room, new[] { Context.ConnectionId })
.SendAsync("Typing", Context.ConnectionId);
}
public async Task Announce(string text)
{
// Shorthand across all connections.
await Clients.Others.SendAsync("Notice", text);
// Longhand. AllExcept takes a list, so it can skip several.
await Clients.AllExcept(new[] { Context.ConnectionId })
.SendAsync("Notice", text);
}
}Choosing which claim becomes the user identifier
The default identifier is the NameIdentifier claim, which is usually a database key your other systems do not know. Swapping in a claim they do know, such as an email address, lets a background job address a person by something it already has. The value must be unique per person, or one user's messages reach another.
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
// Decides what Clients.User(...) matches against.
public class EmailUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection)
=> connection.User?.FindFirst(ClaimTypes.Email)?.Value;
}
// Registered once in Program.cs:
// builder.Services.AddSingleton<IUserIdProvider, EmailUserIdProvider>();
public class NotifyHub : Hub
{
// Reaches every device that person is signed in on.
public Task NotifyOne(string email, string text)
=> Clients.User(email).SendAsync("Notice", text);
public Task NotifyMany(string text)
=> Clients.Users(new[] { "[email protected]", "[email protected]" })
.SendAsync("Notice", text);
}
Discussion