This article looks at how to handle large lists in Blazor, decouple the data from the UI, and use the Notification Pattern to trigger updates in components.
The starting solution for this article is the out-of-the-box Blazor Server template.
The Github Repository for this project is Blazr.Articles.Lists
Our first step is to separate the code into three principle directory structures: Data, Core and UI. Normally I would use three projects, but I'm keeping things simple in this article. These represent the three primary domains in the simple Clean Design model. We'll re-distribute the code as we progress. Code in each domain resides in the domain namespace: for example all Core code resides in Blazr.Articles.Core
.
Move to Core and:
record
value object.WeatherForecastId
property as a Guid
.{get; init;}
.namespace Blazr.Articles.Core; public record WeatherForecast { public Guid WeatherForecastId {get; init;} = Guid.Empty; public DateTime Date { get; init; } public int TemperatureC { get; init; } public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); public string? Summary { get; init; } }
Add a ListOptions
class to Core. This class contains paging data: it's passed in any data pipeline request to define the dataset "page" to retrieve. Code should never retrieve unconstrained lists from data sources - you never know how big the data set may grow. Set the defaults to sensible maximum values. The class has various constructors and setters we'll use later.
using Microsoft.AspNetCore.Components.Web.Virtualization; namespace Blazr.Articles.Core; public class ListOptions { public int StartRecord { get; set; } = 0; public int PageSize { get; set; } = 1000; public int ListCount { get; set; } public int Page => this.StartRecord / this.PageSize; public ListOptions() { } public ListOptions(int startRecord, int pageSize) { this.StartRecord = startRecord; this.PageSize = pageSize; } public void Set(ListOptions options) { this.PageSize = options.PageSize; this.StartRecord = options.StartRecord; } public void Set(ItemsProviderRequest options) { this.PageSize = options.Count; this.StartRecord = options.StartIndex; } public void SetPage(int pageno) => this.StartRecord = pageno * this.PageSize; public ListOptions Copy => new ListOptions { StartRecord = this.StartRecord, PageSize = this.PageSize, ListCount = this.ListCount }; }
Rename the WeatherForcastService
to WeatherForecastDataStore
and restructure it as follows.
We:
ValueTask
based because they emulate real world asynchronous database operations.namespace Blazr.Articles.Data; using Blazr.Articles.Core; public class WeatherForecastDataStore { // Internal list to hold the data set. private List<WeatherForecast> _records; public WeatherForecastDataStore() { _records = GetForecasts(); } private static readonly string[] Summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; public ValueTask<bool> DeleteForecastAsync(Guid Id) { var record = _records.FirstOrDefault(item => item.WeatherForecastId == Id); if (record is not null) _records.Remove(record); return ValueTask.FromResult(record is not null); } public ValueTask<bool> SaveForecastAsync(WeatherForecast record) { var isrecord = _records.Any(item => item.WeatherForecastId == record.WeatherForecastId); if (isrecord) _records.Remove(record); _records.Add(record with { }); return ValueTask.FromResult(isrecord); } public ValueTask<List<WeatherForecast>> GetForecastsAsync(ListOptions options) { var list = _records .OrderBy(item => item.Date) .Skip(options.StartRecord) .Take(options.PageSize) .ToList(); var newList = new List<WeatherForecast>(); list.ForEach(item => newList.Add(item with {})); return ValueTask.FromResult(newList); } public ValueTask<int> GetForecastCountAsync() => ValueTask.FromResult(_records.Count); private List<WeatherForecast> GetForecasts() { return Enumerable.Range(1, 200).Select(index => new WeatherForecast { WeatherForecastId = Guid.NewGuid(), Date = DateTime.Now.AddDays(index), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] }).ToList(); } }
IWeatherForecastDataBroker
defines the interface for the data pipeline connection between Core and Data domain code. Add IWeatherForecastDataBroker
to Core. It defines the basic List and CRUD operations we implement for this article:
Note the simularity with the classic Repository pattern.
namespace Blazr.Articles.Core; public interface IWeatherForecastDataBroker { public ValueTask<List<WeatherForecast>> GetForecastsAsync(ListOptions options); public ValueTask<int> GetForecastCountAsync(); public ValueTask<bool> SaveForecastAsync(WeatherForecast record); public ValueTask<bool> DeleteForecastAsync(Guid Id); }
WeatherForecastServerDataBroker
is the concrete server based implementation of IWeatherForecastDataBroker
. It's commonly known as a "shim": a thin call through layer into the data store. Add WeatherForecastServerDataBroker
to Data.
using Blazr.Articles.Core; namespace Blazr.Articles.Data; public class WeatherForecastServerDataBroker : IWeatherForecastDataBroker { private WeatherForecastDataStore _dataStore; public WeatherForecastServerDataBroker(WeatherForecastDataStore weatherForecastDataStore) => _dataStore = weatherForecastDataStore; public ValueTask<List<WeatherForecast>> GetForecastsAsync(ListOptions options) => _dataStore.GetForecastsAsync(options); public ValueTask<int> GetForecastCountAsync() => _dataStore.GetForecastCountAsync(); public ValueTask<bool> SaveForecastAsync(WeatherForecast record) => _dataStore.SaveForecastAsync(record); public ValueTask<bool> DeleteForecastAsync(Guid Id) => _dataStore.DeleteForecastAsync(Id); }
WeatherForecastNotificationService
is a simple service used by the data services to manage notifications: list updates and page changes. There are two events triggered by two public notify methods.
namespace Blazr.Articles.Core; public class WeatherForecastNotificationService { public event EventHandler? ListUpdated; public event EventHandler? ListPaged; public void NotifyListUpdated(object? sender) => this.ListUpdated?.Invoke(this, EventArgs.Empty); public void NotifyListPaged(object? sender, int page) => this.ListPaged?.Invoke(sender, new PagingEventArgs(page)); }
ListPagedEventArgs
is a derived Eventargs
class:
namespace Blazr.Articles.Core; public class ListPagedEventArgs : EventArgs { public int Page { get; set; } public ListPagedEventArgs(int page) => this.Page = page; }
This provides list management for the UI. Records
is the exposed paged record set. There are two GetRecordsAsync
public methods:
Records
.Virtualize
component and implements the ItemsProviderDelegate
pattern.Both update the local ListOptions
field, get the data set and raise the ListPaged
event on the notification service.
using Microsoft.AspNetCore.Components.Web.Virtualization; namespace Blazr.Articles.Core; public class WeatherForecastListService { private IWeatherForecastDataBroker _weatherForecastDataBroker; private WeatherForecastNotificationService _notificationService; public readonly ListOptions ListOptions = new ListOptions(); public List<WeatherForecast>? Records { get; private set; } public WeatherForecastListService(IWeatherForecastDataBroker weatherForecastDataBroker, WeatherForecastNotificationService weatherForecastNotificationService) { _weatherForecastDataBroker = weatherForecastDataBroker; _notificationService = weatherForecastNotificationService; } public async ValueTask<ListOptions> GetRecordsAsync(ListOptions options) { this.ListOptions.Set(options); await this.GetRecordsAsync(); return this.ListOptions.Copy; } public async ValueTask<ItemsProviderResult<WeatherForecast>> GetRecordsAsync(ItemsProviderRequest request) { this.ListOptions.Set(request); await this.GetRecordsAsync(); return new ItemsProviderResult<WeatherForecast>(this.Records ?? new List<WeatherForecast>(), this.ListOptions.ListCount); } private async ValueTask GetRecordsAsync() { this.Records = await _weatherForecastDataBroker.GetForecastsAsync(this.ListOptions); this.ListOptions.ListCount = await _weatherForecastDataBroker.GetForecastCountAsync(); _notificationService.NotifyListPaged(this, this.ListOptions.Page); } }
WeatherForecastCrudService
contains the Crud services.
Methods raise the appropriate events on the notification service. This service is very simple here, but where the record is more complex and/or record editing is implemented, this class would maintain the working copy of the record.
namespace Blazr.Articles.Core; public class WeatherForecastCrudService { private IWeatherForecastDataBroker _weatherForecastDataBroker; private WeatherForecastNotificationService _notificationService; public WeatherForecastCrudService(IWeatherForecastDataBroker weatherForecastDataBroker, WeatherForecastNotificationService weatherForecastNotificationService) { _weatherForecastDataBroker = weatherForecastDataBroker; _notificationService = weatherForecastNotificationService; } public async ValueTask DeleteRecordAsync(Guid Id) { _ = await _weatherForecastDataBroker.DeleteForecastAsync(Id); _notificationService.NotifyListUpdated(this); } public async ValueTask AddRecordAsync(WeatherForecast record) { _ = await _weatherForecastDataBroker.SaveForecastAsync(record); _notificationService.NotifyListUpdated(this); } }
This completes the data pipeline.
First make a copy of FetchData
. Call it FetchPagedData
, change the Page
directive to FetchPagedData
and add a link to the new poage to NavMenu
.
<div class="nav-item px-3">
<NavLink class="nav-link" href="fetchpageddata">
<span class="oi oi-list-rich" aria-hidden="true"></span> Paged data
</NavLink>
</div>
We'll look at this in stages.
The page:
IDisposable
: events need disposing correctly. The Renderer manages IDisposable
.@page "/fetchdata"
@implements IDisposable
@using Blazr.Articles.Core
@inject WeatherForecastListService ListService
@inject WeatherForecastCrudService CrudService
@inject WeatherForecastNotificationService NotificationService
Virtualize
component.OnListUpdated
handles any list update events. It calls RefreshDataAsync
on the Virtualize
component and StateHasChanged
on the page component.Dispose
to unregister event handlers.@code {
private Virtualize<WeatherForecast>? virtualizeComponent;
protected override void OnInitialized()
=> this.NotificationService.ListUpdated += this.OnListUpdated;
private void OnListUpdated(object? sender, EventArgs e)
{
this.virtualizeComponent?.RefreshDataAsync();
this.InvokeAsync(StateHasChanged);
}
public void Dispose()
=> this.NotificationService.ListUpdated += this.OnListUpdated;
}
WeatherForecastCrudService
.public async Task AddRecord() { var record = new WeatherForecast { WeatherForecastId = Guid.NewGuid(), Date = DateTime.Now, TemperatureC = 20, Summary = "Testing" }; await CrudService.AddRecordAsync(record); } public async Task DeleteRecord(Guid Id) => await CrudService.DeleteRecordAsync(Id);
On to the UI.
We:
Virtualize
component.Virtualize
component as the row template, wired to the WeatherForecastListService
method GetRecordsAsync
.<h1>Weather forecast</h1> <p>This component demonstrates fetching data from a service.</p> <div class="container-fluid"> <div class="col-12 text-end"> <button class="btn btn-sm btn-dark" @onclick="this.AddRecord">Add Record</button> </div> </div> <table class="table"> <thead> <tr> <th>Date</th> <th>Temp. (C)</th> <th>Temp. (F)</th> <th>Summary</th> <th>Actions</th> </tr> </thead> <tbody> <Virtualize TItem=WeatherForecast Context=forecast ItemsProvider=this.ListService.GetRecordsAsync @ref=this.virtualizeComponent> <tr> <td>@forecast.Date.ToShortDateString()</td> <td>@forecast.TemperatureC</td> <td>@forecast.TemperatureF</td> <td>@forecast.Summary</td> <td> <button class="btn btn-sm btn-danger" @onclick="() => this.DeleteRecord(forecast.WeatherForecastId)">Delete</button> </td> </tr> </Virtualize> </tbody> </table>
First, a paging component.
There's a lot of simple maths based fields to calculate and manage the page and block information the display components need. We'll skip those.
The control follows the same pattern as Virtualize
:
ListOptions
class is used to pass and receive paging data.Func
delegate, defined as a required Parameter, to call the paging provider.The main methods are:
GotToPageAsync(int page)
sets the new page and calls SetPageAsync
.GotToPageAsync()
calls SetPageAsync
with the current ListOptions
data.SetPageAsync()
calls the PagingProvider
and updates the list count on the result.private async Task GotToPageAsync(int page) { if (this.PagingProvider is not null && page != this.Page) { this.Page = page; await GotToPageAsync(); } } private async Task GotToPageAsync() { await SetPageAsync(); this.StateHasChanged(); } private async Task SetPageAsync() { if (this.PagingProvider is not null) { var options = await PagingProvider(_listOptions); this.Page = options.Page; this.ListCount = options.ListCount; } }
OnInitializedAsync
gets the current page.
protected async override Task OnInitializedAsync() => await this.SetPageAsync();
The full partial class looks like this:
using Blazr.Articles.Core; using Microsoft.AspNetCore.Components; namespace Blazr.Articles.UI; public partial class PagingControl : ComponentBase { private ListOptions _listOptions => new ListOptions() { PageSize = this.PageSize, StartRecord = this.ReadStartRecord }; private int Page = 0; private int ListCount = 0; [Parameter] public int PageSize { get; set; } = 5; [Parameter] public int BlockSize { get; set; } = 10; [Parameter][EditorRequired] public Func<ListOptions, ValueTask<ListOptions>>? PagingProvider { get; set; } [Parameter] public bool ShowPageOf { get; set; } = true; protected async override Task OnInitializedAsync() => await this.SetPageAsync(); private async Task SetPageAsync() { if (this.PagingProvider is not null) { var options = await PagingProvider(_listOptions); this.Page = options.Page; this.ListCount = options.ListCount; } } private void OnPagingReset(object? sender, PagingEventArgs e) { this.Page = e.Page; this.InvokeAsync(StateHasChanged); } private async Task GotToPageAsync(int page) { if (this.PagingProvider is not null && page != this.Page) { this.Page = page; await GotToPageAsync(); } } private async Task GotToPageAsync() { await SetPageAsync(); this.StateHasChanged(); } public async ValueTask NotifyListChangedAsync() => await GotToPageAsync(); private int DisplayPage => this.Page + 1; private int LastPage => PageSize == 0 || ListCount == 0 ? 0 : ((int)Math.Ceiling(Decimal.Divide(this.ListCount, this.PageSize))) - 1; private int LastDisplayPage => this.LastPage + 1; private int ReadStartRecord => this.Page * this.PageSize; private int Block => (int)Math.Floor(Decimal.Divide(this.Page, this.BlockSize)); private bool AreBlocks => this.ListCount > this.BlockSize * this.PageSize; private int BlockStartPage => this.Block * this.BlockSize; private int BlockEndPage => this.LastPage > (this.BlockStartPage + (BlockSize)) - 1 ? (this.BlockStartPage + BlockSize) - 1 : this.LastPage; private int LastBlock => (int)Math.Floor(Decimal.Divide(this.LastPage, this.BlockSize)); private int LastBlockStartPage => LastBlock * this.BlockSize; private string GetCss(int page) => page == this.Page ? "btn-primary" : "btn-secondary"; private async Task MoveBlockAsync(int block) { var _page = block switch { int.MaxValue => this.LastBlockStartPage, 1 => this.Block + 1 > LastBlock ? LastBlock * this.BlockSize : this.BlockStartPage + BlockSize, -1 => this.Block - 1 < 0 ? 0 : this.BlockStartPage - BlockSize, _ => 0 }; await this.GotToPageAsync(_page); } private async Task GoToBlockAsync(int block) => await this.GotToPageAsync(block * this.PageSize); }
And the Razor markup
@namespace Blazr.Articles.UI @implements IDisposable <div class="m-2 p-2"> @if (this.AreBlocks) { <div class="btn-group me-1" role="group" aria-label="Move Back Buttons"> <button type="button" class="btn btn-sm btn-dark" @onclick="() => this.MoveBlockAsync(int.MinValue)">|<</button> <button type="button" class="btn btn-sm btn-dark" @onclick="() => this.MoveBlockAsync(-1)"><<</button> </div> } <div class="btn-group" role="group" aria-label="Page Buttons"> @for (int page = this.BlockStartPage; page <= this.BlockEndPage; page++) { var pageno = page; var viewpageno = page + 1; <button type="button" class="btn btn-sm @GetCss(pageno)" @onclick="() => this.GotToPageAsync(pageno)">@viewpageno</button> } </div> @if (this.AreBlocks) { <div class="btn-group ms-1" role="group" aria-label="Move Forward Buttons"> <button type="button" class="btn btn-sm btn-dark" @onclick="() => this.MoveBlockAsync(1)">>></button> <button type="button" class="btn btn-sm btn-dark" @onclick="() => this.MoveBlockAsync(int.MaxValue)">>|</button> </div> } @if (this.ShowPageOf) { <span class="mx-2">Page @this.DisplayPage of @this.LastDisplayPage</span> } </div>
We'll look at this in stages.
IDisposable
: events need disposing correctly. The Renderer manages IDisposable
.@using Blazr.Articles.Core
@using Blazr.Articles.UI
@inject WeatherForecastListService ListService
@inject WeatherForecastCrudService CrudService
@inject WeatherForecastNotificationService NotificationService
PageControl
component.OnListUpdated
handles any list update events. It calls NotifyListChangedAsync
on the PagingControl
component and StateHasChanged
on the page component.OnListPaged
handles any paging events. It invokes StateHasChanged
on the page component.Dispose
to unregister event handlers.private PagingControl? pagingControl; protected override void OnInitialized() { this.NotificationService.ListUpdated += this.OnListChanged; this.NotificationService.ListPaged += this.OnListPaged; } private void OnListChanged(object? sender, EventArgs e) { this.pagingControl?.NotifyListChangedAsync(); this.InvokeAsync(StateHasChanged); } private void OnListPaged(object? sender, EventArgs e) => this.InvokeAsync(StateHasChanged); public void Dispose() { this.NotificationService.ListUpdated += this.OnListChanged; this.NotificationService.ListPaged += this.OnListPaged; }
WeatherForecastCrudService
.public async Task AddRecord() { var record = new WeatherForecast { WeatherForecastId = Guid.NewGuid(), Date = DateTime.Now, TemperatureC = 20, Summary = "Testing" }; await CrudService.AddRecordAsync(record); } public async Task DeleteRecord(Guid Id) => await CrudService.DeleteRecordAsync(Id);
In the UI markup we:
Records
collection.<h1>Weather forecast</h1> <p>This component demonstrates fetching data from a service.</p> <div class="container-fluid"> <div class="row"> <div class="col-10"> <PagingControl BlockSize=10 PageSize=10 PagingProvider=this.ListService.GetRecordsAsync @ref=this.pagingControl /> </div> <div class="col-2 text-end"> <button class="btn btn-sm btn-dark" @onclick="this.AddRecord">Add Record</button> </div> </div> </div> @if (this.ListService.Records == null) { <p><em>Loading...</em></p> } else { <table class="table"> <thead> <tr> <th>Date</th> <th>Temp. (C)</th> <th>Temp. (F)</th> <th>Summary</th> <th>Actions</th> </tr> </thead> <tbody> @foreach (var forecast in this.ListService.Records) { <tr> <td>@forecast.Date.ToShortDateString()</td> <td>@forecast.TemperatureC</td> <td>@forecast.TemperatureF</td> <td>@forecast.Summary</td> <td> <button class="btn btn-sm btn-danger" @onclick="() => this.DeleteRecord(forecast.WeatherForecastId)">Delete</button> </td> </tr> } </tbody> </table> }
WeatherForecastListService.Records
will be null.PagingControl
initializes, calling SetPageAsync
and loading the first page.WeatherForecastListService.GetRecordsAsync
loads the initial lage of data into Records
and raises the NotifyListPaged
event on the notifciation service.OnListPaged
on FetchPagedData
is called, which triggers a re-render. WeatherForecastListService.Records
now contains a dataset and is rendered.ListUpdated
event on the notification service.OnListChanged
is called on FetchPagedData
which calls NotifyListChangedAsync
on the PagingControl
.ListChanged
event which re-renders the main page.That's it for this article. We've explored:
Virtualize
component, linked into the data pipeline to load paged data.In a future article I'll look at how to upgrade the data pipeline for sorting and filtering operations, and how to buid a set of generic UI components to handle such operations.