This guide builds a real-time chat room backed entirely by an in-memory service. It demonstrates the core observable query pattern: the frontend subscribes once and receives updates as they arrive — no polling, no manual WebSocket setup.
By the end you will have:
A ChatRoom class that holds per-room message history in a BehaviorSubject
A ChatService singleton that manages named rooms in a ConcurrentDictionary
A ChatMessage observable query that delivers the room’s current history as the initial payload, then pushes updates in real time
A SendMessage command that posts to a room and triggers a push to all subscribers
A React page component that joins a room and renders a live chat thread
ChatRoom owns the per-room state. A BehaviorSubject is the right tool here: it always holds the most recently emitted value and emits that value immediately to any new subscriber, which is exactly how each connecting client receives the room’s current history.
ChatService is a singleton that creates and tracks rooms by name.
Features/Chat/ChatRoom.cs
usingSystem.Collections.Concurrent;
usingSystem.Reactive.Subjects;
namespaceMyApp.Chat;
/// <summary>
/// Holds the message history and live subject for a single chat room.
BehaviorSubject<IEnumerable<ChatMessage>> is a reactive subject with two properties that make it ideal for this use case:
It always holds the most recently emitted value — the full accumulated history — so it acts as both the live stream and the current-state store.
It immediately emits that value to any new subscriber. A client joining mid-conversation receives all past messages in the first push, with no separate history call.
Send() appends the message to _history, then calls OnNext() with a snapshot of the complete list. Sending the full list on each update keeps backend code straightforward. Arc handles network efficiency automatically via delta mode (see Step 3).
ConcurrentDictionary makes GetChatRoom() safe under concurrent access. If two clients join the same room simultaneously, only one ChatRoom is created.
ForRoom(string roomName, ChatService chatService) — the framework distinguishes the two parameters automatically: roomName is a query parameter from the HTTP request; ChatService is resolved from the DI container.
The method creates a relayBehaviorSubject initialised with room.Messages.Value — the BehaviorSubject’s current value, which is the room’s full history at the moment of connection. Subscribing to room.Messages then forwards every future OnNext() call to the relay. Each client gets its own relay instance — independent subscriptions that all start with the same snapshot.
Handle() on SendMessage delegates to ChatRoom.Send(). Because Send() calls _messages.OnNext(), every active relay fires, pushing the updated list to every client subscribed to ForRoom for that room.
Register ChatService as a singleton in your Program.cs:
builder.Services.AddSingleton<ChatService>();
Run dotnet build after saving these files. The Arc proxy generator produces:
ChatMessage.ts — the TypeScript model type
ForRoom.ts — the observable query proxy with use() and when() hooks
SendMessage.ts — the command proxy with a use() hook
Arc observable queries use delta mode by default. Understanding this helps you reason about what crosses the network and how to get the most from it.
What happens on each emission:
Emission
What is sent
First
The complete collection — the room’s full history as the initial payload
Subsequent
A ChangeSet with only the added, replaced, and removed arrays
The use() hook applies each ChangeSet transparently. messagesResult.data always holds the full current collection — the React component never sees raw deltas.
How Arc computes the ChangeSet. The server compares successive OnNext() emissions. If the item type has an id property (case-insensitive), Arc uses identity-based comparison and can detect additions, replacements, and removals independently. Without an id property, Arc falls back to JSON-hash comparison, which can only detect additions and removals.
ChatMessage has no id property, so Arc uses JSON-hash. Since chat messages are immutable — never edited after being sent — only added events occur, which JSON-hash handles correctly. For large histories, adding a ChatMessageId concept improves efficiency by letting Arc skip the full JSON comparison on unchanged items.
Full mode. To send the complete collection on every emission (useful during debugging), set observableQueryTransferMode={ObservableQueryTransferMode.Full} on the <Arc> provider. Delta mode is the default and is recommended for production.
ForRoom.when(joinedRoom.length > 0).use({ roomName: joinedRoom }) — .when(condition) prevents the subscription from opening until the user has joined a room. Once joinedRoom is set, Arc opens an SSE connection. The component immediately receives the room’s full history as the first push. Every subsequent SendMessage command — from any user in that room — triggers a new push, and messagesResult.data updates automatically.
setSendValues then execute() — setSendValues updates the command object with the room name, user name, and message text. sendCommand.execute() sends the HTTP POST. On the server, Handle() calls ChatRoom.Send(), which calls _messages.OnNext(), which fires every relay subscription, which pushes the updated list to every subscriber — including this browser.
Messages are keyed by array index because ChatMessage has no unique identifier. In production, add a ChatMessageId concept to enable stable keys and identity-based delta computation.