Real-Time Updates in Blazor Server with NATS
Implement real-time UI updates in Blazor Server using NATS JetStream instead of SignalR, with progress indicators and state management.
Introduction
When a user uploads a document, they shouldn’t stare at a spinner wondering if anything is happening. They should see real-time progress: “OCR processing… 45%… Entity extraction… Complete!”
The typical solution is SignalR, but Blazor Server already uses SignalR internally for its circuit connection. Adding another SignalR layer for real-time events creates redundancy. Instead, we’ll leverage our existing NATS messaging infrastructure to push updates directly to Blazor components.
What We’ll Build
In this guide, we’ll implement:
- In-process notification service — Decouples NATS from Blazor components
- NATS event listener — Bridges external events to the notification service
- Base component class — Handles subscriptions, filtering, and cleanup
- Document uploader — Shows real-time upload and processing progress
- Live document list — Updates automatically when documents change
Why NATS Instead of SignalR?
- Consistency: Workers already publish to NATS; no need for a separate notification channel
- Simplicity: One messaging system instead of two
- Scalability: NATS handles cross-pod communication naturally; SignalR needs Redis backplane
- Already there: No additional infrastructure to manage
Architecture Overview
The data flow from worker to UI:
flowchart LR
Worker["⚙️ Worker\n(Publishes)"] --> NATS["⚡ NATS\n(JetStream)"]
NATS --> Listener["👂 Listener\n(Hosted)"]
Listener --> Notifier["📢 Notifier\n(Service)"]
Notifier --> CompA["🟣 Component A"]
Notifier --> CompB["🟣 Component B"]
Notifier --> CompC["🟣 Component C"]
classDef primary fill:#7c3aed,color:#fff
classDef secondary fill:#06b6d4,color:#fff
classDef db fill:#f43f5e,color:#fff
classDef warning fill:#fbbf24,color:#000
class Worker,Listener,Notifier,CompA,CompB,CompC primary
class NATS secondary
Key insight: The DocumentProcessingNotifier acts as an in-process event bus. The NATS listener translates external messages to C# events; components subscribe to only the documents they care about.
The Notification Service
The notification service provides an in-process event aggregation layer. Components subscribe to it; they never know about NATS directly.
Why this indirection?
- Testability: Unit tests can call
NotifyDocumentUpdatedAsyncwithout NATS - Flexibility: Could swap NATS for another source without changing components
- Filtering: Components subscribe to specific document IDs; the service broadcasts to all
// Application/Services/IDocumentProcessingNotifier.cs
namespace Archives.Application.Services;
public interface IDocumentProcessingNotifier
{
/// <summary>
/// Event raised when a document's processing status changes.
/// </summary>
event Func<DocumentStatusUpdate, Task>? OnDocumentUpdated;
/// <summary>
/// Event raised for processing progress updates.
/// </summary>
event Func<DocumentProgressUpdate, Task>? OnProgressUpdated;
/// <summary>
/// Notifies subscribers of a document status change.
/// </summary>
Task NotifyDocumentUpdatedAsync(
string documentId,
DocumentStatus status,
string? message = null);
/// <summary>
/// Notifies subscribers of processing progress.
/// </summary>
Task NotifyProgressUpdatedAsync(
string documentId,
string stage,
int percentage,
string? detail = null);
}
public record DocumentStatusUpdate(
string DocumentId,
DocumentStatus Status,
string? Message,
DateTimeOffset Timestamp);
public record DocumentProgressUpdate(
string DocumentId,
string Stage,
int Percentage,
string? Detail,
DateTimeOffset Timestamp);
Implementation
The implementation iterates through handlers, catching exceptions to prevent one bad handler from breaking the notification chain:
// Web/Services/DocumentProcessingNotifier.cs
namespace Archives.Web.Services;
public sealed class DocumentProcessingNotifier : IDocumentProcessingNotifier
{
private readonly ILogger<DocumentProcessingNotifier> _logger;
public event Func<DocumentStatusUpdate, Task>? OnDocumentUpdated;
public event Func<DocumentProgressUpdate, Task>? OnProgressUpdated;
public DocumentProcessingNotifier(ILogger<DocumentProcessingNotifier> logger)
{
_logger = logger;
}
public async Task NotifyDocumentUpdatedAsync(
string documentId,
DocumentStatus status,
string? message = null)
{
var update = new DocumentStatusUpdate(
documentId,
status,
message,
DateTimeOffset.UtcNow);
_logger.LogDebug(
"Notifying status update: {DocumentId} -> {Status}",
documentId,
status);
var handlers = OnDocumentUpdated;
if (handlers is not null)
{
// Invoke all handlers, catching exceptions to avoid breaking the chain
foreach (var handler in handlers.GetInvocationList()
.Cast<Func<DocumentStatusUpdate, Task>>())
{
try
{
await handler(update);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error in document update handler for {DocumentId}",
documentId);
}
}
}
}
public async Task NotifyProgressUpdatedAsync(
string documentId,
string stage,
int percentage,
string? detail = null)
{
var update = new DocumentProgressUpdate(
documentId,
stage,
Math.Clamp(percentage, 0, 100), // Ensure valid range
detail,
DateTimeOffset.UtcNow);
var handlers = OnProgressUpdated;
if (handlers is not null)
{
foreach (var handler in handlers.GetInvocationList()
.Cast<Func<DocumentProgressUpdate, Task>>())
{
try
{
await handler(update);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error in progress handler for {DocumentId}",
documentId);
}
}
}
}
}
NATS Event Listener
This hosted service bridges NATS events to the notification service. It uses an ephemeral consumer since we only care about real-time updates—if the web server restarts, we don’t need to replay missed events.
// Web/Services/NatsDocumentEventListener.cs
namespace Archives.Web.Services;
public sealed class NatsDocumentEventListener : BackgroundService
{
private readonly INatsConnection _nats;
private readonly IDocumentProcessingNotifier _notifier;
private readonly ILogger<NatsDocumentEventListener> _logger;
private readonly string _environment;
public NatsDocumentEventListener(
INatsConnection nats,
IDocumentProcessingNotifier notifier,
IConfiguration configuration,
ILogger<NatsDocumentEventListener> logger)
{
_nats = nats;
_notifier = notifier;
_logger = logger;
_environment = configuration["Environment"] ?? "dev";
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var js = _nats.CreateJetStreamContext();
// Ephemeral consumer: unique name, short inactivity timeout, no ack required
var consumer = await js.CreateOrUpdateConsumerAsync(
stream: "DOCUMENTS",
config: new ConsumerConfig
{
// Unique name per instance for horizontal scaling
Name = $"web-{Environment.MachineName}-{Guid.NewGuid():N}",
// Subscribe to all document events for this environment
FilterSubject = $"{_environment}.archives.documents.>",
// Only receive new messages (not replays)
DeliverPolicy = ConsumerConfigDeliverPolicy.New,
// Fire-and-forget: missing a notification isn't critical
AckPolicy = ConsumerConfigAckPolicy.None,
// Auto-cleanup when this pod dies
InactiveThreshold = TimeSpan.FromMinutes(5)
},
stoppingToken);
_logger.LogInformation(
"NATS document event listener started for {Environment}",
_environment);
try
{
await foreach (var msg in consumer.ConsumeAsync<DocumentEventEnvelope>(
cancellationToken: stoppingToken))
{
await HandleEventAsync(msg.Subject, msg.Data!, stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("NATS document event listener stopping");
}
}
Event mapping: The listener translates NATS subjects to notification calls. Subject structure is {env}.archives.documents.{event-type}:
private async Task HandleEventAsync(
string subject,
DocumentEventEnvelope envelope,
CancellationToken ct)
{
try
{
// Extract event type from subject: "dev.archives.documents.ocr-completed" -> "ocr-completed"
var eventType = subject.Split('.').Last();
switch (eventType)
{
case "uploaded":
await _notifier.NotifyDocumentUpdatedAsync(
envelope.DocumentId,
DocumentStatus.Uploaded,
"Document queued for processing");
break;
case "ocr-started":
await _notifier.NotifyDocumentUpdatedAsync(
envelope.DocumentId,
DocumentStatus.Processing,
"Starting OCR...");
await _notifier.NotifyProgressUpdatedAsync(
envelope.DocumentId,
"OCR",
0,
"Initializing document processor");
break;
case "ocr-progress":
if (envelope.Progress is not null)
{
await _notifier.NotifyProgressUpdatedAsync(
envelope.DocumentId,
"OCR",
envelope.Progress.Percentage,
envelope.Progress.Detail);
}
break;
case "ocr-completed":
await _notifier.NotifyProgressUpdatedAsync(
envelope.DocumentId,
"OCR",
100,
"Text extraction complete");
break;
case "analysis-completed":
await _notifier.NotifyProgressUpdatedAsync(
envelope.DocumentId,
"Analysis",
100,
"AI analysis complete");
break;
case "embedding-completed":
await _notifier.NotifyProgressUpdatedAsync(
envelope.DocumentId,
"Indexing",
100,
"Search index updated");
break;
case "indexed":
await _notifier.NotifyDocumentUpdatedAsync(
envelope.DocumentId,
DocumentStatus.Ready,
"Document ready for search");
break;
case "error":
await _notifier.NotifyDocumentUpdatedAsync(
envelope.DocumentId,
DocumentStatus.Error,
envelope.Error ?? "Processing failed");
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error handling event {Subject} for {DocumentId}",
subject,
envelope.DocumentId);
}
}
}
public record DocumentEventEnvelope
{
public required string DocumentId { get; init; }
public ProgressInfo? Progress { get; init; }
public string? Error { get; init; }
}
public record ProgressInfo(int Percentage, string? Detail);
Service Registration
// Program.cs
builder.Services.AddSingleton<IDocumentProcessingNotifier, DocumentProcessingNotifier>();
builder.Services.AddHostedService<NatsDocumentEventListener>();
Blazor Component Base
Now the Blazor side. We create a base component that handles the subscription boilerplate and—critically—cleanup when components dispose.
Why cleanup matters: Blazor Server components can be disposed at any time (navigation, circuit disconnect). Without unsubscribing, you get memory leaks and exceptions when handlers try to invoke StateHasChanged on disposed components.
// Components/BlueRobinComponentBase.cs
namespace Archives.Web.Components;
public abstract class BlueRobinComponentBase : ComponentBase, IDisposable
{
[Inject]
protected IDocumentProcessingNotifier Notifier { get; set; } = default!;
[Inject]
protected ILogger<BlueRobinComponentBase> Logger { get; set; } = default!;
private readonly List<IDisposable> _subscriptions = new();
private bool _disposed;
/// <summary>
/// Subscribe to updates for a specific document.
/// Automatically filters events and triggers StateHasChanged.
/// </summary>
protected void SubscribeToDocumentUpdates(
string documentId,
Func<DocumentStatusUpdate, Task> handler)
{
// Wrap handler to filter by document ID and trigger re-render
async Task FilteredHandler(DocumentStatusUpdate update)
{
if (update.DocumentId == documentId)
{
await handler(update);
await InvokeAsync(StateHasChanged);
}
}
Notifier.OnDocumentUpdated += FilteredHandler;
_subscriptions.Add(new EventSubscription(() =>
Notifier.OnDocumentUpdated -= FilteredHandler));
}
/// <summary>
/// Subscribe to progress updates for a specific document.
/// </summary>
protected void SubscribeToProgressUpdates(
string documentId,
Func<DocumentProgressUpdate, Task> handler)
{
async Task FilteredHandler(DocumentProgressUpdate update)
{
if (update.DocumentId == documentId)
{
await handler(update);
await InvokeAsync(StateHasChanged);
}
}
Notifier.OnProgressUpdated += FilteredHandler;
_subscriptions.Add(new EventSubscription(() =>
Notifier.OnProgressUpdated -= FilteredHandler));
}
/// <summary>
/// Subscribe to all document updates (useful for lists).
/// </summary>
protected void SubscribeToAllDocumentUpdates(
Func<DocumentStatusUpdate, Task> handler)
{
async Task WrappedHandler(DocumentStatusUpdate update)
{
await handler(update);
await InvokeAsync(StateHasChanged);
}
Notifier.OnDocumentUpdated += WrappedHandler;
_subscriptions.Add(new EventSubscription(() =>
Notifier.OnDocumentUpdated -= WrappedHandler));
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// Unsubscribe from all events
foreach (var subscription in _subscriptions)
{
subscription.Dispose();
}
_subscriptions.Clear();
}
_disposed = true;
}
/// <summary>
/// Helper class to track subscriptions for cleanup.
/// </summary>
private sealed class EventSubscription : IDisposable
{
private Action? _unsubscribe;
public EventSubscription(Action unsubscribe) => _unsubscribe = unsubscribe;
public void Dispose()
{
_unsubscribe?.Invoke();
_unsubscribe = null;
}
}
}
Document Upload Component
Now let’s build a complete upload component that shows real-time progress. This component:
- Handles file selection via drag-and-drop or file picker
- Uploads to the API
- Subscribes to processing events for real-time progress
- Updates the UI as events arrive
@* Components/Documents/DocumentUploader.razor *@
@inherits BlueRobinComponentBase
<div class="space-y-4">
@* Upload Area *@
<div class="@DropZoneClass"
@ondragenter="HandleDragEnter"
@ondragleave="HandleDragLeave"
@ondragover:preventDefault
@ondrop="HandleDrop"
@ondrop:preventDefault>
<InputFile OnChange="HandleFileSelected"
accept=".pdf,.docx,.pptx,.png,.jpg,.jpeg"
class="hidden"
id="file-input" />
<label for="file-input" class="cursor-pointer flex flex-col items-center">
<svg class="w-12 h-12 text-gray-400 mb-3" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<span class="text-gray-600 dark:text-gray-300">
Drop files here or click to browse
</span>
<span class="text-sm text-gray-400 mt-1">
PDF, Word, PowerPoint, or images up to 50MB
</span>
</label>
</div>
@* Upload Queue *@
@if (uploads.Count > 0)
{
<div class="space-y-2">
@foreach (var upload in uploads)
{
<DocumentUploadCard Upload="upload" OnRemove="RemoveUpload" />
}
</div>
}
</div>
@code {
[Parameter] public string ArchiveId { get; set; } = default!;
[Parameter] public EventCallback<string> OnUploadComplete { get; set; }
private List<UploadState> uploads = new();
private bool isDragOver;
private string DropZoneClass => new CssBuilder()
.AddClass("border-2 border-dashed rounded-xl p-8 text-center transition-colors")
.AddClass("border-gray-300 dark:border-gray-600", !isDragOver)
.AddClass("border-robin-blue bg-robin-blue/5", isDragOver)
.Build();
private void HandleDragEnter() => isDragOver = true;
private void HandleDragLeave() => isDragOver = false;
private async Task HandleDrop(DragEventArgs e)
{
isDragOver = false;
// File handling via InputFile is more reliable
}
private async Task HandleFileSelected(InputFileChangeEventArgs e)
{
foreach (var file in e.GetMultipleFiles(10))
{
var upload = new UploadState(file.Name, file.Size);
uploads.Add(upload);
// Start upload asynchronously
_ = UploadFileAsync(upload, file);
}
}
private async Task UploadFileAsync(UploadState upload, IBrowserFile file)
{
try
{
upload.Status = UploadStatus.Uploading;
StateHasChanged();
// Subscribe to updates for this document
// (DocumentId will be set after API response)
using var content = new MultipartFormDataContent();
using var stream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024);
using var streamContent = new StreamContent(stream);
content.Add(streamContent, "file", file.Name);
content.Add(new StringContent(ArchiveId), "archiveId");
var response = await Http.PostAsync("/api/documents/upload", content);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<UploadResponse>();
upload.DocumentId = result!.DocumentId;
upload.Status = UploadStatus.Processing;
// Now subscribe to NATS updates for this specific document
SubscribeToDocumentUpdates(upload.DocumentId, update =>
{
upload.StatusMessage = update.Message;
if (update.Status == DocumentStatus.Ready)
{
upload.Status = UploadStatus.Complete;
OnUploadComplete.InvokeAsync(upload.DocumentId);
}
else if (update.Status == DocumentStatus.Error)
{
upload.Status = UploadStatus.Error;
upload.Error = update.Message;
}
return Task.CompletedTask;
});
SubscribeToProgressUpdates(upload.DocumentId, progress =>
{
upload.Stage = progress.Stage;
upload.Progress = progress.Percentage;
upload.ProgressDetail = progress.Detail;
return Task.CompletedTask;
});
}
catch (Exception ex)
{
upload.Status = UploadStatus.Error;
upload.Error = ex.Message;
Logger.LogError(ex, "Upload failed for {FileName}", file.Name);
}
StateHasChanged();
}
private void RemoveUpload(UploadState upload)
{
uploads.Remove(upload);
}
[Inject] private HttpClient Http { get; set; } = default!;
}
public class UploadState
{
public string FileName { get; }
public long FileSize { get; }
public string? DocumentId { get; set; }
public UploadStatus Status { get; set; } = UploadStatus.Pending;
public string? Stage { get; set; }
public int Progress { get; set; }
public string? ProgressDetail { get; set; }
public string? StatusMessage { get; set; }
public string? Error { get; set; }
public UploadState(string fileName, long fileSize)
{
FileName = fileName;
FileSize = fileSize;
}
}
public enum UploadStatus { Pending, Uploading, Processing, Complete, Error }
public record UploadResponse(string DocumentId);
Upload Card with Progress
The upload card displays file info, status, and a progress bar. The key is the progress bar that updates in real-time as events arrive:
@* Components/Documents/DocumentUploadCard.razor *@
<div class="glass-card p-4">
<div class="flex items-center gap-4">
@* File Icon *@
<div class="w-10 h-10 rounded-lg bg-robin-blue/10 flex items-center justify-center">
<FileIcon FileName="@Upload.FileName" />
</div>
@* File Info *@
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 dark:text-white truncate">
@Upload.FileName
</p>
<p class="text-sm text-gray-500">
@FormatBytes(Upload.FileSize)
@if (!string.IsNullOrEmpty(Upload.StatusMessage))
{
<span class="ml-2">• @Upload.StatusMessage</span>
}
</p>
</div>
@* Status Indicator *@
<div class="flex items-center gap-2">
@switch (Upload.Status)
{
case UploadStatus.Pending:
<span class="text-gray-400">Waiting...</span>
break;
case UploadStatus.Uploading:
<div class="animate-spin w-5 h-5 border-2 border-robin-blue
border-t-transparent rounded-full" />
<span class="text-robin-blue">Uploading</span>
break;
case UploadStatus.Processing:
<div class="animate-pulse w-5 h-5 bg-amber-400 rounded-full" />
<span class="text-amber-600 dark:text-amber-400">Processing</span>
break;
case UploadStatus.Complete:
<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
<span class="text-green-600 dark:text-green-400">Complete</span>
break;
case UploadStatus.Error:
<svg class="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd" />
</svg>
<span class="text-red-600 dark:text-red-400">Failed</span>
break;
}
@* Remove Button *@
@if (Upload.Status is UploadStatus.Complete or UploadStatus.Error)
{
<button @onclick="() => OnRemove.InvokeAsync(Upload)"
class="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
}
</div>
</div>
@* Progress Bar *@
@if (Upload.Status == UploadStatus.Processing && Upload.Progress > 0)
{
<div class="mt-3">
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span>@Upload.Stage</span>
<span>@Upload.Progress%</span>
</div>
<div class="h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div class="h-full bg-robin-blue transition-all duration-300 rounded-full"
style="width: @(Upload.Progress)%" />
</div>
@if (!string.IsNullOrEmpty(Upload.ProgressDetail))
{
<p class="text-xs text-gray-400 mt-1">@Upload.ProgressDetail</p>
}
</div>
}
@* Error Message *@
@if (Upload.Status == UploadStatus.Error && !string.IsNullOrEmpty(Upload.Error))
{
<p class="mt-2 text-sm text-red-500">@Upload.Error</p>
}
</div>
@code {
[Parameter, EditorRequired] public UploadState Upload { get; set; } = default!;
[Parameter] public EventCallback<UploadState> OnRemove { get; set; }
private string FormatBytes(long bytes)
{
string[] sizes = ["B", "KB", "MB", "GB"];
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len /= 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}
Document List with Live Updates
Finally, a document list that updates in real-time when new documents are added or statuses change. This uses SubscribeToAllDocumentUpdates since it needs to react to any document, not just a specific one:
@* Components/Documents/DocumentList.razor *@
@inherits BlueRobinComponentBase
@implements IAsyncDisposable
<div class="space-y-4">
@foreach (var doc in documents)
{
<DocumentCard Document="doc" />
}
@if (documents.Count == 0)
{
<div class="text-center py-12 text-gray-500">
<p>No documents yet</p>
</div>
}
</div>
@code {
[Parameter] public string ArchiveId { get; set; } = default!;
private List<DocumentViewModel> documents = new();
protected override async Task OnInitializedAsync()
{
// Load initial documents
documents = await LoadDocumentsAsync();
// Subscribe to all document updates for this archive
SubscribeToAllDocumentUpdates(async update =>
{
var doc = documents.FirstOrDefault(d => d.Id == update.DocumentId);
if (doc is not null)
{
// Update existing document
doc.Status = update.Status;
doc.UpdatedAt = update.Timestamp;
}
else if (update.Status == DocumentStatus.Uploaded)
{
// New document - refresh list
documents = await LoadDocumentsAsync();
}
});
}
private async Task<List<DocumentViewModel>> LoadDocumentsAsync()
{
var response = await Http.GetFromJsonAsync<DocumentListResponse>(
$"/api/archives/{ArchiveId}/documents");
return response?.Documents ?? new();
}
[Inject] private HttpClient Http { get; set; } = default!;
public async ValueTask DisposeAsync()
{
// BlueRobinComponentBase.Dispose handles unsubscription
Dispose();
await ValueTask.CompletedTask;
}
}
Conclusion
We’ve built a complete real-time update system for Blazor Server that leverages existing NATS infrastructure:
| Component | Purpose |
|---|---|
| IDocumentProcessingNotifier | In-process event aggregation |
| NatsDocumentEventListener | Bridges NATS → notification service |
| BlueRobinComponentBase | Subscription management + cleanup |
| DocumentUploader | Real-time upload + processing progress |
| DocumentList | Live list updates |
Key patterns:
- Event filtering: Components subscribe to specific document IDs; filtering happens locally
- Automatic cleanup: Base component disposes subscriptions to prevent memory leaks
- Fire-and-forget NATS: No acks needed for UI notifications; missed events aren’t critical
- InvokeAsync(StateHasChanged): Always marshal UI updates to the Blazor synchronization context
This approach keeps the architecture simple: one messaging system (NATS) for both backend workers and frontend notifications, no SignalR hubs to configure, no Redis backplane for scaling.
Next in the series: We’ll build advanced form components with validation, error states, and glass morphism styling.