Toast Notification Service
A small pub/sub service that lets any component or service fire a toast ("Saved!", "Session expired") without a direct reference to whatever's rendering it. One NotificationHost component sits in the layout and reacts to events.
The service
Plain C#, no UI code — just an event and a couple of methods. Registered as a singleton (or scoped, in Blazor Server) so every component sees the same instance.
public class NotificationService
{
public event Action<Notification>? OnNotify;
public void Success(string message) => Notify(message, NotificationLevel.Success);
public void Error(string message) => Notify(message, NotificationLevel.Error);
public void Info(string message) => Notify(message, NotificationLevel.Info);
private void Notify(string message, NotificationLevel level)
=> OnNotify?.Invoke(new Notification(Guid.NewGuid(), message, level));
}
public record Notification(Guid Id, string Message, NotificationLevel Level);
public enum NotificationLevel { Success, Info, Error }
The host component — one per layout
Subscribes on init, keeps a small list of active toasts, auto-dismisses each after a few seconds, and unsubscribes on dispose so it doesn't leak when the layout is torn down.
@implements IDisposable
@inject NotificationService Notifications
<div class="toast-stack">
@foreach (var n in _active)
{
<div class="toast toast-@n.Level.ToString().ToLower()">
@n.Message
</div>
}
</div>
@code {
private readonly List<Notification> _active = [];
protected override void OnInitialized()
{
Notifications.OnNotify += HandleNotify;
}
private async void HandleNotify(Notification n)
{
_active.Add(n);
await InvokeAsync(StateHasChanged);
await Task.Delay(4000);
_active.Remove(n);
await InvokeAsync(StateHasChanged);
}
public void Dispose()
{
Notifications.OnNotify -= HandleNotify;
}
}
Registration and usage
services.AddSingleton<NotificationService>();
Drop <NotificationHost /> once in MainLayout.razor, then fire toasts from anywhere — a page after a failed API call, or even a background service:
@inject NotificationService Notifications
@inject IAuthService AuthService
@code {
private async Task HandleLogin()
{
var response = await AuthService.LoginAsync(request);
if (!response.Succeeded)
{
Notifications.Error(response.Message ?? "Login failed.");
return;
}
Notifications.Success("Welcome back!");
Navigation.NavigateTo("/dashboard");
}
}
Because the service has no dependency on any specific component, the same NotificationService.Error(...) call works from a page, from HttpService itself (e.g. on every failed request), or from a background timer — anywhere DI reaches.
Alternative: a static event, no DI at all
The DI-service version above is the more testable, more "correct" shape. In practice, a static class with a static event works just as well for a small app and is simpler to call from places that don't have easy access to DI — a static extension method, for instance. This is the shape actually used in production for Activadis:
public class Notification
{
public Guid Id { get; set; }
public bool HasErrored { get; set; }
public string Message { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
public int DurationInMs { get; set; }
public DateTime SendOn { get; set; }
}
public static class Notifications
{
public static event Action<Notification>? OnNotification;
public static void AddNotification(string? message, bool hasErrored = false, int durationInMs = 2500)
{
OnNotification?.Invoke(new Notification
{
Id = Guid.NewGuid(),
Message = message ?? "An unexpected error occurred.",
HasErrored = hasErrored,
DurationInMs = durationInMs,
SendOn = DateTime.UtcNow
});
}
}
// call from literally anywhere, no injection needed:
Notifications.AddNotification("Saved!", hasErrored: false);
The host component marks a notification IsDeleted instead of removing it from the list immediately, so a click-to-dismiss handler and the auto-expiry delay can't race each other over the same list mutation.
@implements IDisposable
<div class="notifications">
@foreach (var n in notifications.Where(x => !x.IsDeleted).OrderByDescending(x => x.SendOn))
{
<div @key="n.Id" class="notification @(n.HasErrored ? "notification-error" : "")"
@onclick="@(() => n.IsDeleted = true)">
<p>@n.Message</p>
</div>
}
</div>
@code {
public List<Notification> notifications { get; private set; } = [];
protected override void OnInitialized()
=> Notifications.OnNotification += OnNotification;
public void Dispose()
=> Notifications.OnNotification -= OnNotification;
public void OnNotification(Notification notification)
{
InvokeAsync(async () =>
{
notifications.Add(notification);
StateHasChanged();
await Task.Delay(notification.DurationInMs);
notifications.Remove(notification);
StateHasChanged();
});
}
}
Trade-off: a static event is a form of global mutable state — every subscriber in the app shares the exact same event, which makes per-user isolation in Blazor Server (where multiple users' circuits run in the same process) impossible without extra work. Fine for Blazor WASM, where each user already gets their own process in the browser; think twice before using it in Server mode with multiple concurrent users.