Blazor Virtualization Component Example for Large Data Handling with Responsive Scrolling

Handling large datasets in web applications has always been a challenge. Traditional approaches like server-side paging can sometimes feel clunky and interrupt the user experience. The Blazor Virtualization component enhances performance and responsiveness by rendering only visible elements, which significantly reduces memory and processing overhead. This article dives into the workings of the Blazor Virtualization component compared to traditional paging through practical code examples.

Blazor and the Virtualization Component

Blazor is a .NET web UI framework for building rich, interactive web applications using my favorite language, C#. Blazor empowers developers to create efficient Single Page Applications (SPAs). One of its most impressive features is the Virtualization component, because it optimizes the rendering of large data sets. Rather than loading and rendering all of the data items at once, Virtualization only displays the items currently visible to the user, dynamically adding or removing elements as the user scrolls.

Key Features

  • Performance optimization: Reduces DOM manipulation and rendering workload.
  • Responsive scrolling: Ensures smooth, fast navigation through large datasets.
  • Dynamic data handling: Supports filtering, searching, and sorting seamlessly.

Code Example: API-Driven Table with Filtering and Paging (without Virtualization)

This Blazor component fetches user data from a public API (https://randomuser.me/api) and displays it in a paginated and searchable table.

Key Features

  • Loading data dynamically from an API.
  • Searching through the dataset by user name or email.
  • Paginating the results, showing a fixed number of records per page.

Pros

  • Paging is a familiar navigation pattern.
  • Efficient for smaller datasets.

Cons

  • Requires user interaction for navigation (clicking Next).
  • Not suitable for very large datasets due to potential performance bottlenecks.
@page "/PagingList"
@rendermode RenderMode.InteractiveServer

<PageTitle>People</PageTitle>
<h3 class="my-3">Blazor API Data List with Filtering and Paging</h3>

@{
// Error
if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger" role="alert">
@errorMessage
</div>
}
// Loading
else if (people is null)
{
<div class="alert alert-secondary" role="alert">
Loading...
</div>
}
// Results
else
{
// Empty Results Inhibit Display
if (people.Any())
{
<!-- Search and Pagination Controls -->
<div class="my-3" style="display: flex; align-items: center; gap: 10px;">
<input type="search" class="form-control" style="width: auto;" @bind="searchTerm" @oninput="OnSearchTermChanged" placeholder="Search by Name or Email" />
<button @onclick="PreviousPage" disabled="@(!CanGoPrevious)">Previous</button>
<span>Page @currentPage of @totalPages</span>
<button @onclick="NextPage" disabled="@(!CanGoNext)">Next</button>
</div>
}

<!-- Table Data -->
<div class="my-3">
<table style="width: 100%; border-collapse: collapse; margin-top: 20px;">
<thead>
<tr>
<th style="border: 1px solid black; padding: 8px;">Person Id</th>
<th style="border: 1px solid black; padding: 8px;">First Name</th>
<th style="border: 1px solid black; padding: 8px;">Last Name</th>
<th style="border: 1px solid black; padding: 8px;">Email</th>
</tr>
</thead>
<tbody>
@if (!people.Any())
{
<tr>
<td colspan="4" style="border: 1px solid black; padding: 8px;">There are no people available.</td>
</tr>
}
@foreach (var p in currentPageItems)
{
<tr>
<td style="border: 1px solid black; padding: 8px;">@p.Id</td>
<td style="border: 1px solid black; padding: 8px;">@p.FirstName</td>
<td style="border: 1px solid black; padding: 8px;">@p.LastName</td>
<td style="border: 1px solid black; padding: 8px;">@p.Email</td>
</tr>
}
</tbody>
</table>
</div>
}
}


@code {
// Init
private List<Person>? people;
private List<Person> filteredPeople = new();
private List<Person> currentPageItems = new();
private string errorMessage = string.Empty;
private string searchTerm = string.Empty;

// Paginate
private int pageSize = 20;
private int currentPage = 1;
private int totalPages => (int)Math.Ceiling((double)filteredPeople.Count / pageSize);
private bool CanGoPrevious => currentPage > 1;
private bool CanGoNext => currentPage < totalPages;

// On Initialize
protected override async Task OnInitializedAsync()
{
await FetchDataFromApi();
FilterPeople(); // Initialize filtered list
LoadPageData();
}

// On Filter
private void OnSearchTermChanged(ChangeEventArgs e)
{
searchTerm = e.Value?.ToString() ?? string.Empty;
FilterPeople();
}

// Fetch
private async Task FetchDataFromApi()
{
try
{
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync("https://randomuser.me/api/?results=100");
if (response.IsSuccessStatusCode)
{
var apiResponse = await response.Content.ReadFromJsonAsync<RandomUserApiResponse>();
if (apiResponse?.Results != null)
{
// Map API data to Person objects
people = apiResponse.Results.Select((user, index) => new Person
{
Id = index + 1,
FirstName = user.Name.First,
LastName = user.Name.Last,
Email = user.Email
}).ToList();
}
}
else
{
errorMessage = $"Error: Unable to fetch users. (Status Code: {response.StatusCode})";
}
}
catch(Exception ex)
{
errorMessage = $"Error: {ex.Message}";
}
}

// Filter
private void FilterPeople()
{
if (string.IsNullOrWhiteSpace(searchTerm))
{
filteredPeople = people!;
}
else
{
filteredPeople = people!
.Where(p => p.FirstName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) ||
p.LastName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) ||
p.Email.Contains(searchTerm, StringComparison.OrdinalIgnoreCase))
.ToList();
}
currentPage = 1;
LoadPageData();
}

// Pagination
private void LoadPageData()
{
currentPageItems = filteredPeople.Skip((currentPage - 1) * pageSize).Take(pageSize).ToList();
}

// Paginate - Previous
private void PreviousPage()
{
if (CanGoPrevious)
{
currentPage--;
LoadPageData();
}
}

// Paginate - Next
private void NextPage()
{
if (CanGoNext)
{
currentPage++;
LoadPageData();
}
}

// Models
private class Person
{
public int Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}

private class RandomUserApiResponse
{
public List<ApiUser> Results { get; set; } = new();
}

private class ApiUser
{
public Name Name { get; set; } = new();
public string Email { get; set; } = string.Empty;
}

private class Name
{
public string First { get; set; } = string.Empty;
public string Last { get; set; } = string.Empty;
}
}

Explanation

  1. Initialization: OnInitializedAsync()
  • Fetches data from the API.
  • Initializes filteredPeople (the filtered dataset) and loads the first page.

2. Data Fetching: FetchDataFromApi()

  • Calls the API to get user data and maps it to a Person object with properties like IdFirstNameLastName, and Email.
  • Handles errors by populating errorMessage if the request fails.

3. Filtering: FilterPeople()

  • Filters people based on the searchTerm input.
  • Resets the current page to the first page (currentPage = 1) after applying the filter.

4. Pagination: LoadPageData()

  • Calculates the data for the current page using Skip and Take LINQ methods.

5. PreviousPage and NextPage

  • Navigate to the previous or next page if navigation is allowed.

6. Search Event: OnSearchTermChanged()

  • Triggered whenever the search input changes.
  • Updates searchTerm and re-applies filtering.

Code Example: Virtualized Filterable Table with Scrolling

This example a filterable virtual list for displaying data fetched from an API. The main features include API integration, a virtualized list for efficient rendering of large datasets, and a live search filter.

Key Features and Workflow

1. Component Initialization

  • The component initializes with OnInitializedAsync, which asynchronously loads user data from an external API (https://randomuser.me/api/) using the LoadUsersAsync method.

2. Loading and Error Handling

  • During the loading process, a placeholder is displayed (Loading...).
  • If an error occurs, such as a failed API request, an error message is displayed (Error: Unable to fetch users...).

3. Search Functionality

  • Users can filter the displayed data by typing in a search box. The @bind="searchTerm" directive binds the search term to the searchTerm property.
  • The @oninput="OnSearchTermChanged" event triggers the FilterUsers method to update the filtered dataset.

4. Virtualized Rendering

  • The <Virtualize> component efficiently renders only the visible rows in the list. This is crucial for performance when dealing with large datasets.
  • Items: Binds to filteredUsers, the filtered dataset.
  • ItemContent: Template for rendering individual items.
  • EmptyContent: Fallback content displayed when no users match the search criteria.

5. Data Fetching and Transformation

  • Data Fetching: The API call retrieves 100 random user records.
  • Transformation: The raw API response is mapped to a Person object containing IdFirstNameLastName, and Email.

Pros

  1. Significantly reduces memory and rendering load, ideal for large datasets.
  2. Provides a smoother, more modern user experience with continuous scrolling.

Cons

  1. Slightly more complex to implement and debug.
  2. Initial setup for Virtualize may require additional configuration for optimal performance.
@page "/VirtualList"
@rendermode RenderMode.InteractiveServer

<PageTitle>User List</PageTitle>
<h3 class="my-3">Blazor Filterable Virtual List with API Data</h3>

@{
// Error
if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger" role="alert">
@errorMessage
</div>
}
// Loading
else if (users is null)
{
<div class="alert alert-secondary" role="alert">
Loading...
</div>
}
// Results
else
{
<div class="my-3">
<input type="search" class="form-control" @bind="searchTerm" @oninput="OnSearchTermChanged" placeholder="Search by Name or Email" />
</div>

<article class="my-3">
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger" role="alert">
@errorMessage
</div>
}
else if (users is null)
{
<div class="alert alert-secondary" role="alert">
Loading...
</div>
}
else
{
<table style="width: 100%; border-collapse: collapse; margin-top: 20px;">
<thead>
<tr>
<th style="border: 1px solid black; padding: 8px;">Id</th>
<th style="border: 1px solid black; padding: 8px;">First Name</th>
<th style="border: 1px solid black; padding: 8px;">Last Name</th>
<th style="border: 1px solid black; padding: 8px;">Email</th>
</tr>
</thead>
<tbody>
<Virtualize Items="filteredUsers" ItemSize="80" Context="user" SpacerElement="tr" OverscanCount="3">
<EmptyContent>
<tr>
<td colspan="4" style="text-align:center; padding: 10px;">No users available.</td>
</tr>
</EmptyContent>
<ItemContent>
<tr>
<td style="border: 1px solid black; padding: 8px;">@user.Id</td>
<td style="border: 1px solid black; padding: 8px;">@user.FirstName</td>
<td style="border: 1px solid black; padding: 8px;">@user.LastName</td>
<td style="border: 1px solid black; padding: 8px;">@user.Email</td>
</tr>
</ItemContent>
</Virtualize>
</tbody>
</table>
}
</article>
}
}

@code {
// Init
private List<Person>? users;
private List<Person> filteredUsers = new();
private string searchTerm = string.Empty;
private string errorMessage = string.Empty;

// On Initialize
protected override async Task OnInitializedAsync()
{
await LoadUsersAsync();
}

// Filter
private void FilterUsers()
{
if (string.IsNullOrWhiteSpace(searchTerm))
{
filteredUsers = users!;
}
else
{
filteredUsers = users!
.Where(p => p.FirstName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) ||
p.LastName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) ||
p.Email.Contains(searchTerm, StringComparison.OrdinalIgnoreCase))
.ToList();
}
}

// Fetch
private async Task LoadUsersAsync()
{
try
{
users = null; // Start with null for loading state
errorMessage = string.Empty; // Clear any previous errors

// api call
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync("https://randomuser.me/api/?results=100");

// results
if (response.IsSuccessStatusCode)
{
var apiResponse = await response.Content.ReadFromJsonAsync<RandomUserApiResponse>();
if (apiResponse?.Results != null)
{
// Map API data to Person objects
users = apiResponse.Results.Select((user, index) => new Person
{
Id = index + 1,
FirstName = user.Name.First,
LastName = user.Name.Last,
Email = user.Email
}).ToList();
}
}
else
{
errorMessage = $"Error: Unable to fetch users. (Status Code: {response.StatusCode})";
}

// Filter
FilterUsers();
}
catch (Exception ex)
{
errorMessage = $"Error: {ex.Message}";
}
}

// On Filter
private void OnSearchTermChanged(ChangeEventArgs e)
{
searchTerm = e.Value?.ToString() ?? string.Empty;
FilterUsers();
}

// Models
private class Person
{
public int Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
private class ApiUser
{
public Name Name { get; set; } = new();
public string Email { get; set; } = string.Empty;
}
private class UserApiResponse
{
public List<ApiUser> Results { get; set; } = new();
}
private class Name
{
public string First { get; set; } = string.Empty;
public string Last { get; set; } = string.Empty;
}
private class RandomUserApiResponse
{
public List<ApiUser> Results { get; set; } = new();
}
}

Virtualization Component

  1. Data Source
  • The Items="filteredUsers" attribute specifies the collection to be virtualized. Only the visible rows (and a small buffer of rows above and below) from filteredUsers will be rendered at any given time.

2. Item Size

  • The ItemSize="80" attribute indicates the estimated height (in pixels) of each item in the list. This value is used to calculate which items are currently visible within the scrollable area and to manage the placeholders (spacers) for non-rendered items.

3. Context

  • The Context="user" attribute provides a variable (user) representing each item in the collection when it is rendered within the ItemContent block.

4. Content Templates

  • ItemContent: Defines how each item in the collection is rendered. For example, each user in filteredUsers will render as a <tr> with the user’s details (IdFirstNameLastName, and Email).
  • EmptyContent: Specifies the content to display if the filteredUsers collection is empty. In this case, a <tr> with a message “No users available” spanning all columns.

5. Spacer Element

  • The SpacerElement="tr" attribute ensures that the placeholders for items outside the visible range are rendered as <tr> elements to maintain the structure of the <table>.

6. OverscanCount

  • The OverscanCount property in the <Virtualize> component specifies the number of additional items to render before and after the visible set, improving smooth scrolling by preloading nearby items. Setting it to 3 means three items above and below the current viewport will be rendered, reducing lag during fast scrolling.

How It Works

  1. Dynamic Rendering
  • Instead of rendering all rows in filteredUsers, the <Virtualize> component calculates the visible range of rows based on the size of the viewport and ItemSize.
  • Only these visible rows are rendered as <tr> elements in the DOM, significantly improving performance for large datasets.

2. Scrolling Behavior

  • As the user scrolls through the table, the <Virtualize> component dynamically updates the rendered rows based on the new visible range.
  • Non-visible items are replaced by spacer elements (<tr>), maintaining the table’s scrollable height and structure without rendering unnecessary rows.

3. Performance Benefits

  • This approach reduces the number of DOM elements rendered at any given time, which improves the rendering speed and responsiveness of the UI, especially when filteredUsers contains thousands of items.

Use Case

If filteredUsers contains 10,000 users:

  • Initially, only enough rows to fill the visible viewport (e.g., 20 rows) are rendered.
  • As the user scrolls down, the next set of rows (e.g., rows 21–40) are rendered dynamically.
  • Rows outside the visible range are replaced with spacer <tr> elements.

This behavior is especially beneficial for large tables, as it prevents performance bottlenecks associated with rendering too many rows simultaneously.

Explanation

  1. Initialization: OnInitializedAsync()
  • Fetches data from the API.
  • Initializes filteredPeople (the filtered dataset) and loads the first page.

2. Data Fetching: FetchDataFromApi()

  • Calls the API to get user data and maps it to a Person object with properties like IdFirstNameLastName, and Email.
  • Handles errors by populating errorMessage if the request fails.

3. Filtering: FilterPeople()

  • Filters people based on the searchTerm input.
  • Resets the current page to the first page (currentPage = 1) after applying the filter.

The Blazor Virtualization component is a game-changer for applications dealing with extensive datasets. While traditional paging still has its use cases, Virtualization offers a performance-focused alternative that enhances responsiveness and user experience. By dynamically loading only the visible data, it ensures that modern web applications remain both fast and efficient, even with demanding data requirements. Whether you’re building a dashboard, a catalog, or any data-heavy interface, Blazor Virtualization is a tool worth mastering.