Real-time live comments combine REST (for the initial comment load and submission) with SignalR (for instant delivery to other viewers). The optimistic update pattern makes the experience feel instant — the commenter’s own comment appears immediately while the server processes it, with a rollback if submission fails. Other viewers receive the comment within milliseconds via the SignalR broadcast.
Live Comments Component
@Component({
selector: 'app-comments',
standalone: true,
imports: [ReactiveFormsModule, MatFormFieldModule, MatButtonModule, DatePipe],
template: `
<!-- Viewer count ────────────────────────────────────────────────────── -->
@if (viewerCount() > 1) {
<p class="viewers">👁 {{ viewerCount() }} people reading this post</p>
}
<!-- Comment list ────────────────────────────────────────────────────── -->
<section class="comments-list">
<h3>Comments ({{ comments().length }})</h3>
@for (comment of comments(); track comment.id) {
<article class="comment" [class.pending]="comment.isPending">
<img [src]="comment.authorAvatarUrl" alt="" width="32" height="32">
<div>
<strong>{{ comment.authorName }}</strong>
@if (comment.isPending) { <em> · Submitting...</em> }
<time>{{ comment.createdAt | date:'short' }}</time>
<p>{{ comment.body }}</p>
</div>
</article>
} @empty {
<p class="empty">No comments yet. Be the first!</p>
}
</section>
<!-- Comment form — authenticated users only ──────────────────────── -->
@if (auth.isLoggedIn()) {
<form (ngSubmit)="onSubmit()">
<mat-form-field class="full-width">
<mat-label>Write a comment</mat-label>
<textarea matInput [formControl]="bodyControl" rows="3"
maxlength="2000"></textarea>
</mat-form-field>
<button mat-raised-button color="primary"
[disabled]="bodyControl.invalid || isSubmitting()">
Post Comment
</button>
</form>
} @else {
<p><a routerLink="/auth/login">Sign in</a> to leave a comment.</p>
}
`,
})
export class CommentsComponent implements OnInit, OnDestroy {
@Input({ required: true }) postId!: number;
private signalR = inject(SignalRService);
private commentsApi = inject(CommentsApiService);
protected auth = inject(AuthService);
private destroyRef = inject(DestroyRef);
comments = signal<CommentDto[]>([]);
viewerCount = signal(1);
isSubmitting = signal(false);
bodyControl = new FormControl('',
[Validators.required, Validators.minLength(3), Validators.maxLength(2000)]);
ngOnInit() {
// Load existing comments from REST
this.commentsApi.getByPost(this.postId).subscribe(
list => this.comments.set(list)
);
// Join SignalR room
this.signalR.joinPostRoom(this.postId);
// Receive new comments in real-time
this.signalR.comments$.pipe(
filter(c => c.postId === this.postId),
takeUntilDestroyed(this.destroyRef),
).subscribe(comment => {
// Skip if it's our own optimistic comment (already in the list)
if (!this.comments().some(c => c.id === comment.id))
this.comments.update(list => [...list, comment]);
});
// Track viewer count
this.signalR.viewerCount$.pipe(
filter(v => v.postId === this.postId),
takeUntilDestroyed(this.destroyRef),
).subscribe(v => this.viewerCount.set(v.count));
}
async onSubmit(): Promise<void> {
if (this.bodyControl.invalid) return;
const body = this.bodyControl.value!;
// ── Optimistic update ─────────────────────────────────────────────────
const optimistic: CommentDto = {
id: -Date.now(), // temporary negative ID
postId: this.postId,
body,
authorName: this.auth.displayName(),
authorAvatarUrl: this.auth.currentUser()?.avatarUrl ?? '',
createdAt: new Date().toISOString(),
isApproved: true,
isPending: true, // shows "Submitting..." indicator
};
this.comments.update(list => [...list, optimistic]);
this.bodyControl.reset();
this.isSubmitting.set(true);
try {
// Submit via hub — server saves and broadcasts to all viewers
await this.signalR.sendComment(this.postId, body);
// Remove the optimistic placeholder (real comment arrives via ReceiveComment)
this.comments.update(list => list.filter(c => c.id !== optimistic.id));
} catch {
// Rollback on failure
this.comments.update(list => list.filter(c => c.id !== optimistic.id));
this.bodyControl.setValue(body); // restore the text
} finally {
this.isSubmitting.set(false);
}
}
ngOnDestroy(): void { this.signalR.leavePostRoom(this.postId); }
}
-Date.now()) to distinguish it from real server-issued IDs (which are positive integers). When the real comment arrives via SignalR’s ReceiveComment event, the handler checks whether the comment ID already exists in the list — skipping it if found (for the current user who submitted and whose real comment just arrived). The optimistic placeholder (negative ID) is then removed. This prevents duplicate display.sendComment) rather than a REST API call. This is a valid architecture choice — the hub method saves the comment to the database AND broadcasts it to all viewers in one step. Alternatively, submit via REST API and have the API layer broadcast via SignalR from the server. The hub-based approach reduces round-trips; the REST approach is easier to test and more standard. Both work — choose based on your testing strategy and team conventions.ngOnDestroy calling leavePostRoom() is critical. Without it, the user’s connection ID remains in the post-{id} group even after navigating away. The viewer count stays inflated, and the user continues receiving comment events for a post they’re no longer viewing. Angular’s OnDestroy lifecycle hook is the correct place to clean up SignalR group memberships — it fires reliably when the component is removed from the DOM.Common Mistakes
Mistake 1 — Not leaving the SignalR group on component destroy (inflated viewer counts)
❌ Wrong — no LeavePostRoom call in ngOnDestroy; viewer count stays high; events delivered to disconnected viewers.
✅ Correct — ngOnDestroy calls signalR.leavePostRoom(this.postId); hub decrements count and removes from group.
Mistake 2 — Duplicate comments when own submission arrives via SignalR
❌ Wrong — optimistic comment shown + real comment from SignalR both displayed; user sees their comment twice.
✅ Correct — check if comment ID already exists before adding; remove optimistic placeholder when real comment arrives.