Creating a DataGrid component in Blazor
In a previous post, I explained how to create a repeater component to encapsulate the layout logic into reusable components. In this post, I'll show you how to create a more complex component to display a data grid. The goal of the component is to render a <table>
element from a list of columns and a list of objects.
At the end, you'll be able to use the grid component as follow:
<Grid Items="customers" class="table table-bordered" RowClass='(row, index) => row.OrdersCount > 5 ? "table-success" : ""'>
<GridColumn TRowData="Customer" Expression="c => c.Id" />
<GridColumn TRowData="Customer" Expression="c => c.Name" />
<GridColumn TRowData="Customer" Expression="c => c.DateOfBirth" Format="d" />
<GridColumn TRowData="Customer" Title="# Orders">@context.OrdersCount orders</GridColumn>
<GridColumn TRowData="Customer">
<a href="/edit/@context.Id">edit</a>
</GridColumn>
</Grid>
As you can see there are 2 components: Grid
and GridColumn
. The Grid
component is responsible for the rendering of the table. The GridColumn
defines a column to be rendered by the Grid
and the cell content using an expression or a template. Blazor cannot infer the type of GridColumn
generic type, so you must specify it using TRowData
.
The code is inspired from the code in this merge request: https://github.com/dotnet/aspnetcore/pull/23301. I've adapted it to make it simpler and support expressions to define columns.
First, let's create the Grid.razor
file with the following content:
@typeparam TRowData
@*
<CascadingValue> allows descendant components (defined in ChildContent) to receive the specified value.
Child components need to declare a cascading parameter with the same type as "Value" (i.e. Grid<TRowData>).
This allows GridColumn to get the Grid instance by using a CascadingParameter
[CascadingParameter]public Grid<TRowData> OwnerGrid { get; set; }
IsFixed="true" indicates that "Value" will not change. This is a
performance optimization that allows the framework to skip setting up
change notifications.
*@
<CascadingValue IsFixed="true" Value="this">@ChildContent</CascadingValue>
@* Render the table *@
<table @attributes="@TableAttributes">
<thead>
<tr>
@foreach (var column in columns)
{
@column.HeaderTemplate
}
</tr>
</thead>
<tbody>
@{
if (Items != null)
{
var index = 0;
foreach (var item in Items)
{
@* Use @key to help the diff algorithm when updating the collection *@
<tr @key="item.GetHashCode()" class="@(RowClass?.Invoke(item, index++))">
@foreach (var column in columns)
{
@column.CellTemplate(item)
}
</tr>
}
}
}
</tbody>
</table>
@code {
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> TableAttributes { get; set; }
[Parameter]
public ICollection<TRowData> Items { get; set; }
// This fragment should contains all the GridColumn
[Parameter]
public RenderFragment ChildContent { get; set; }
[Parameter]
public Func<TRowData, int, string> RowClass { get; set; }
private readonly List<GridColumn<TRowData>> columns = new List<GridColumn<TRowData>>();
// GridColumn uses this method to add a column
internal void AddColumn(GridColumn<TRowData> column)
{
columns.Add(column);
}
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
// The first render will instantiate the GridColumn defined in the ChildContent.
// GridColumn calls AddColumn during its initialization. This means that until
// the first render is completed, the columns collection is empty.
// Calling StateHasChanged() will re-render the component, so the second time it will know the columns
StateHasChanged();
}
}
}
Then, create the GridColumn.razor
file with the following content:
@typeparam TRowData
@using System.Linq.Expressions
@using Humanizer
@code {
[CascadingParameter]
public Grid<TRowData> OwnerGrid { get; set; }
[Parameter]
public string Title { get; set; }
[Parameter]
public Expression<Func<TRowData, object>> Expression { get; set; }
[Parameter]
public string Format { get; set; }
[Parameter]
public RenderFragment<TRowData> ChildContent { get; set; }
private Func<TRowData, object> compiledExpression;
private Expression lastCompiledExpression;
private RenderFragment headerTemplate;
private RenderFragment<TRowData> cellTemplate;
// Add the column to the parent Grid component.
// OnInitialized is called only once in the component lifecycle
protected override void OnInitialized()
{
OwnerGrid.AddColumn(this);
}
protected override void OnParametersSet()
{
if (lastCompiledExpression != Expression)
{
compiledExpression = Expression?.Compile();
lastCompiledExpression = Expression;
}
}
internal RenderFragment HeaderTemplate
{
get
{
return headerTemplate ??= (builder =>
{
// Use the provided title or infer it from the expression
var title = Title;
if (title == null && Expression != null)
{
// Decamelize the property name (requires Humanizer.Core NuGet package). Add the following line in the csproj:
// <PackageReference Include="Humanizer.Core" Version="2.8.26" />
title = GetMemberName(Expression).Humanize();
// If you don't want to decamelize the name you can use the following code instead of the previous line
//title = GetMemberName(Expression);
}
builder.OpenElement(0, "th");
builder.AddContent(1, title);
builder.CloseElement();
});
}
}
internal RenderFragment<TRowData> CellTemplate
{
get
{
return cellTemplate ??= (rowData => builder =>
{
builder.OpenElement(0, "td");
if (compiledExpression != null)
{
var value = compiledExpression(rowData);
var formattedValue = string.IsNullOrEmpty(Format) ? value?.ToString() : string.Format("{0:" + Format + "}", value);
builder.AddContent(1, formattedValue);
}
else
{
builder.AddContent(2, ChildContent, rowData);
}
builder.CloseElement();
});
}
}
// Get the Member name from an expression.
// (customer => customer.Name) returns "Name"
private static string GetMemberName<T>(Expression<T> expression)
{
return expression.Body switch
{
MemberExpression m => m.Member.Name,
UnaryExpression u when u.Operand is MemberExpression m => m.Member.Name,
_ => throw new NotSupportedException("Expression of type '" + expression.GetType().ToString() + "' is not supported")
};
}
}
And that's all! You can now use the Grid
component as in the first example.
#Additional resources
- ASP.NET Core Blazor cascading values and parameters
- ASP.NET Core Blazor templated components
- Blazor grid performance scenarios
Do you have a question or a suggestion about this post? Contact me!