Creating a DataGrid component in Blazor

  • Gérald Barré

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

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee