The three previous guides all emit the full message history on every OnNext() call. Arc’s delta mode compresses this down to a ChangeSet over the wire, but the backend still accumulates and sends a growing list.
This guide flips the model. The backend emits only what is new on each push. The first emission is the full history (initial payload); every subsequent emission contains only the messages that just arrived. The frontend accumulates them into its own local state.
The result is a constant-size network payload per message regardless of how long the conversation has been running.
By the end you will have:
A ChatRoom with a plain Subject — no history, no accumulated state, just a pub/sub channel
A ChatService that tracks history separately and exposes a Send() method
A ForRoom query that emits history once, then forwards only new messages via a ReplaySubject
A React component that uses use() and a useEffect accumulator — notuseChangeStream()
ChatRoom is now a pure pub/sub channel. It holds no state and tracks no history. A plain Subject<IEnumerable<ChatMessage>> emits only when Deliver() is called.
History tracking moves to ChatService, which also becomes the entry point for sending messages so that it can record each message before firing the room’s subject.
Features/Chat/ChatRoom.cs
usingSystem.Collections.Concurrent;
usingSystem.Reactive.Subjects;
namespaceMyApp.Chat;
/// <summary>
/// A pure pub/sub channel for a single chat room.
/// Holds no history — delivers only the messages passed to <seecref="Deliver"/>.
Plain Subject<IEnumerable<ChatMessage>> only delivers values to subscribers that are currently active. Unlike a BehaviorSubject, it holds no current value and emits nothing to late subscribers. This is deliberate — history is the responsibility of ChatService, not the room.
ChatService.Send() records the message in _history under a lock before delivering it to the room. The lock protects the per-room List<ChatMessage> from concurrent appends while remaining uncontested in typical usage. The message is added to history before the pub/sub delivery so that any concurrent GetHistory() call (e.g. a second client joining the room at the same moment) sees the new message in the initial payload.
ReplaySubject<IEnumerable<ChatMessage>>(1) is the right relay here for a specific reason. The method calls OnNext(history) and then subscribes to the room — but Arc subscribes to the returned relay after the method returns. A plain Subject would have already fired and lost the history emission by the time Arc subscribes. ReplaySubject(1) stores the last emitted value and replays it to each new subscriber immediately upon subscription, so Arc always receives the history as its first message.
The Subject in ChatRoom fires once per Deliver() call with a single-element collection. The relay forwards each of these to Arc as a separate push.
Register ChatService as a singleton in your Program.cs:
builder.Services.AddSingleton<ChatService>();
Run dotnet build after saving. The proxy generator produces ForRoom.ts, SendMessage.ts, and ChatMessage.ts — identical in shape to the other chat guides.
With the backend emitting incremental payloads, this is what the frontend sees in Arc’s delta mode:
Push
Backend emits
Arc ChangeSet sent
messagesResult.data
1st — history
[msg1, msg2, msg3]
added: [msg1, msg2, msg3]
[msg1, msg2, msg3]
2nd — new msg
[msg4]
removed: [msg1,msg2,msg3], added: [msg4]
[msg4]
3rd — new msg
[msg5]
removed: [msg4], added: [msg5]
[msg5]
Arc’s ChangeSet computation compares successive emissions — it sees the previous full history disappear and a single new message appear. This looks odd internally, but messagesResult.data from use() accurately reflects what the backend emitted: the history on the first push, and only the new message on every subsequent push.
This is why the frontend must not use useChangeStream() here. useChangeStream() would expose the removed side of the ChangeSet, making it appear that history was deleted on every new message. use() abstracts that away and gives the component the clean per-emission data.
useEffect on messagesResult.data — each time the server pushes a new value, messagesResult.data is a new array reference, triggering the effect. On the first push it contains the full history; on each subsequent push it contains one new message. Appending via setMessages(prev => [...prev, ...data]) works correctly in both cases.
setMessages([]) on join — clears local state before changing rooms. Without this, the previous room’s messages would remain visible for a moment after joining.
use() not useChangeStream() — as explained in Step 3, useChangeStream() would expose the Arc-internal ChangeSet where previous messages appear as removed on each new push, which is the wrong mental model for this pattern.