This article shows how to build a simple Bootstrap Toaster in Blazor and demonstrates several programming techniques and coding patterns that are applicable to almost all Blazor projects.
Introduction
This article shows how to build a simple Bootstrap Toaster in Blazor.
It demonstrates several programming principles and coding patterns that are applicable in almost all Blazor applications.
-
Separation of Concerns - Data doesn't belong in the UI. The Toaster UI component contains no data or data management. Its job is to display toasts.
-
The Blazor Show/Hide Pattern - I was reluctant to call this a pattern, but the number of times I've see programmers trying to achieve this with JSInterop changed my mind. This pattern implements CSS framework .Show()
and .Hide()
JavaScript functionality in C# in the component.
-
The Blazor Notification Pattern - decouples UI components from the underlying data that drive their behaviour using events.
-
Value Objects - Modern design emphasises the use of value objects wherever appropriate.
Repo and Demo Site
You can find the code in my Blazr.Demo.Toaster Repo.
A demo site can be found at https://blazr-demo-database-server.azurewebsites.net.
Code Classes
Toast
First an enum
for the message colour. It uses Bootstrap nomenclature directly to make building Css strings simple.
public enum MessageColour
{
Primary, Secondary, Dark, Light, Success, Danger, Warning, Info
}
Toast
is declared as a value object. Once we've created an instance, we have no reason to change it.
- Toast is declared as a
record
. - There are five
public
properties that are used by the UI to display the toast. All are declared as immutable with { get; init; }
. TimeToBurn
uses DateTimeOffset
to give timezone independant absolute time.
public record Toast
{
public Guid Id = Guid.NewGuid();
public string Title { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
public MessageColour MessageColour { get; init; } = MessageColour.Primary;
public DateTimeOffset TimeToBurn { get; init; } = DateTimeOffset.Now.AddSeconds(30);
Next:
Posted
is a object creation timestamp. IsBurnt
is a bool
to check toast expiration. elapsedTime
is used to build ElapsedTimeText
. ElapsedTimeText
is used by the UI Component.
public readonly DateTimeOffset Posted = DateTimeOffset.Now;
public bool IsBurnt => TimeToBurn < DateTimeOffset.Now;
private TimeSpan elapsedTime => Posted - DateTimeOffset.Now;
public string ElapsedTimeText =>
elapsedTime.Seconds > 60
? $"posted {-elapsedTime.Minutes} mins ago"
: $"posted {-elapsedTime.Seconds} secs ago";
Finally, a static
constructor helper method.
public static Toast NewToast(string title, string message,
MessageColour messageColour, int secsToLive)
=> new Toast
{
Title = title,
Message = message,
MessageColour = messageColour,
TimeToBurn = DateTimeOffset.Now.AddSeconds(secsToLive)
};
Toaster Service
ToasterService
is a Dependancy Injection service that holds and manages Toasts. It has a private list to hold the toasts with add and clear methods. There's a timer to trigger ClearBurntToast
to clear out expired toasts and if necessary, raise the ToasterChanged
event. It also raises the ToasterTimerElapsed
event on each timer cycle.
- It's a standard class.
- Implements
IDisposable
as it registers an event handler with the timer that needs disposing correctly. - Has a read only private collection of
Toast
instances. The list is managed internally. - Has an internal timer that drives the classic service behaviour.
public class ToasterService : IDisposable
{
private readonly List<Toast> _toastList = new List<Toast>();
private System.Timers.Timer _timer = new System.Timers.Timer();
There are two public
events that other services or UI components can subscribe to.
ToasterChanged
is raised whenever the toast list is changed. ToasterTimerElapsed
is raised on each timer loop. HasToasts
is a simple status bool.
public event EventHandler? ToasterChanged;
public event EventHandler? ToasterTimerElapsed;
public bool HasToasts => _toastList.Count > 0;
ClearBurntToast
is our toast list management method. It checks to see it there's any burnt toast. It there is, it clears them out and raises the ToasterChanged
event.
private bool ClearBurntToast()
{
var toastsToDelete = _toastList.Where(item => item.IsBurnt).ToList();
if (toastsToDelete is not null && toastsToDelete.Count > 0)
{
toastsToDelete.ForEach(toast => _toastList.Remove(toast));
this.ToasterChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
TimerElapsed
is our event handler for the timer elapsed event. It clears any burnt toast and raises the ToasterTimerElapsed
event.
private void TimerElapsed(object? sender, ElapsedEventArgs e)
{
this.ClearBurntToast();
this.ToasterTimerElapsed?.Invoke(this, EventArgs.Empty);
}
The constructor adds a welcome Toast
, sets up the timer and registers the event handler.
public ToasterService()
{
AddToast(new Toast { Title = "Welcome Toast",
Message = "Welcome to this Application. I'll disappear after 15 seconds.",
TTD = DateTimeOffset.Now.AddSeconds(10) });
_timer.Interval = 5000;
_timer.AutoReset = true;
_timer.Elapsed += this.TimerElapsed;
_timer.Start();
}
The CRUD type operations are self explanatory. Each calls ClearBurntToast
to run the management method.
public List<Toast> GetToasts()
{
ClearBurntToast();
return _toastList;
}
public void AddToast(Toast toast)
{
_toastList.Add(toast);
<span class="pl-c">
if (!this.ClearBurntToast())
this.ToasterChanged?.Invoke(this, EventArgs.Empty);
}
public void ClearToast(Toast toast)
{
if (_toastList.Contains(toast))
{
_toastList.Remove(toast);
<span class="pl-c">
if (!this.ClearBurntToast())
this.ToasterChanged?.Invoke(this, EventArgs.Empty);
}
}
Finally, the Dispose
method clears the timer event handler.
public void Dispose()
{
if (_timer is not null)
{
_timer.Elapsed += this.TimerElapsed;
_timer.Stop();
}
}
}
ToasterService
can run as either a Scoped
or Singleton
service, depending on what you want it to do.
Toaster
Toaster
is the UI component.
The razor markup implements the Bootstrap Toast markup, with a foreach
loop to add each toast. The markup will display the toasts stacked in the top right.
- The component checks if there's anything to display -
this.toasterService.HasToasts
. If not, then no content is rendered - the Blazor Show/Hide Pattern. @_toastCss
gets the correct Css string for the message colour. - The close X calls
this.ClearToast(toast)
to remove a toast.
@implements IDisposable
@if (this.toasterService.HasToasts)
{
<div class="">
<div class="toast-container position-absolute top-0 end-0 mt-5 pt-5 pe-2">
@foreach (var toast in this.toasterService.GetToasts())
{
var _toastCss = toastCss(toast);
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header @_toastCss">
<strong class="me-auto">@toast.Title</strong>
<small class="@_toastCss">@toast.ElapsedTimeText</small>
<button type="button" class="btn-close btn-close-white"
aria-label="Close" @onclick="() => this.ClearToast(toast)"></button>
</div>
<div class="toast-body">
@toast.Message
</div>
</div>
}
</div>
</div>
}
The code behind class:
- inherits from
ComponentBase
and implements IDisposable
to unhook event handlers. - injects the
ToasterService
. - creates a second
null
forgiving reference to the ToasterService
. We're in the nullable world, but the C# compiler doesn't know ToasterService
can't be null
, so we create a second null
forgiving reference so we don't need to null
forgive every time we use the ToasterService
instance.
public partial class Toaster : ComponentBase, IDisposable
{
[Inject] private ToasterService? _toasterService { get; set; }
private ToasterService toasterService => _toasterService!;
ToastChanged
is the event handler for the ToasterService
events. It invokes StateHasChanged
on the UI thread.
private void ToastChanged(object? sender, EventArgs e)
=> this.InvokeAsync(this.StateHasChanged);
OnInitialized
registers the two ToasterService
events to ToastChanged
, and Dispose
removes them.
protected override void OnInitialized()
{
this.toasterService.ToasterChanged += ToastChanged;
this.toasterService.ToasterTimerElapsed += ToastChanged;
}
public void Dispose()
{
this.toasterService.ToasterChanged -= ToastChanged;
this.toasterService.ToasterTimerElapsed -= ToastChanged;
}
Finally, ClearToast
clears the selected toast from the service and toastCss
gets the toast background colour.
private void ClearToast(Toast toast)
=> toasterService.ClearToast(toast);
private string toastCss(Toast toast)
{
var colour = Enum.GetName(typeof(MessageColour), toast.MessageColour)?.ToLower();
return toast.MessageColour switch
{
MessageColour.Light => "bg-light",
_ => $"bg-{colour} text-white"
};
}
}
Implementing
-
Add ToasterService
to the DI service container in Program.
-
Add the component to either Layout
or App
or wherever you wish to use it.
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly" PreferExactMatches="@true">
....
</Router>
</CascadingAuthenticationState>
<Toaster />
- Inject the service into any pages where you want to raise toasts, and call
AddToast
. The example below shows a demo Index
page.
@page "/"
<PageTitle>Index</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<SurveyPrompt Title="How is Blazor working for you?" />
<div class="m-2 p-2">
<button class="btn btn-primary" @onclick="AddToast" >Add a Toast</button>
</div>
@code {
[Inject] private ToasterService? _toasterService {get; set;}
private ToasterService toasterService => _toasterService!;
private void AddToast()
=> toasterService.AddToast(Toast.NewToast
("Hello World", "Hello from Blazor", MessageColour.Info, 30));
}
Wrap Up
The design demonstrates a clean separation of data from UI. All data handling happens in ToasterService
. Toaster
uses references to the data objects in ToasterService
.
The Blazor notification pattern is used to update the UI whenever the toast list changes. Toaster
registers an event handler with the two ToasterService
events which re-renders the component whenever an event occurs.
Toaster
demonstrates how to show and hide UI markup based on state.
Toast
is a value object. It simplifies equality checking (we don't do any here) and ensures toasts can't be modified once created.
History
- 18th January, 2022: Initial version