Razor Pages And Bootstrap - Lazy Loading Tabs

Tabbed interfaces are a great way for managing the presentation of large amounts of information into separate panels, where each panel's data makes sense on its own, and only one panel is viewable at a time. The tabs in a browser are a great example of this. From a Razor Pages developer's point of view, tabs are particularly useful for controlling the display of complex data in Line Of Business applications.

Right from the start, developers are taught to minimse the amount of database calls to only those necessary to obtain the data for a view. Tyically, in a tabbed interface, the view consists of the content of the first tab only:

The user may only need to see the contacts in this example. If you obtain the data for all the other tabs unnecessarily, this can hurt your application's performance. Resources are required to extract the data from the database, generate the HTML for the other tabs, and render that in the browser. If users perceive your application to be sluggish, they soon become frustrated with it, leading to a greater likelihood of the application being rejected.

The solution is to only load data for other tabs on demand - a pattern known as Lazy Loading.

The following example illustrates use of the pattern within a Razor Pages application. To begin, here is a PageModel that gets a product from a database:

public class TabsModel : PageModel
{
    private readonly IOrderService orderService;
 
    public TabsModel(IProductService productService)
    {
        this.productService = productService;
    }
 
    [BindProperty(SupportsGet = true)]
    public int ProductId { get; set; } = 1;
    public Product Product { get; set; }

    public async Task OnGetAsync()
    {
        Product = await productService.GetProductAsync(ProductId);
    }
}

Here's a tabbed interface that utilises Bootstrap:

<h1 class="display-4">Lazy Loading Tabs From Database</h1>
<h3>@Model.Product.ProductName</h3>
<input type="hidden" asp-for="Product.ProductId" />
<ul class="nav nav-tabs" id="myTab" role="tablist">
    <li class="nav-item">
        <a class="nav-link active" id="product-tab" data-toggle="tab" href="#product" aria-controls="product" aria-selected="true">Details</a>
    </li>
    <li class="nav-item">
        <a class="nav-link" id="supplier-tab" data-toggle="tab" href="#supplier" aria-controls="supplier" aria-selected="false">Supplier</a>
    </li>
    <li class="nav-item">
        <a class="nav-link" id="orders-tab" data-toggle="tab" href="#orders" aria-controls="orders" aria-selected="false">Orders</a>
    </li>
</ul>
<div class="tab-content p-3 border-right border-left">
    <div class="tab-pane fade show active" id="product" role="tabpanel" aria-labelledby="product-tab">
        <dl class="row">
            <dt class="col-sm-2">Quantity Per Unit</dt><dd class="col-sm-10">@Model.Product.QuantityPerUnit</dd>
            <dt class="col-sm-2">Unit Price:</dt><dd class="col-sm-10">@Model.Product.UnitPrice</dd>
            <dt class="col-sm-2">Units In Stock:</dt><dd class="col-sm-10">@Model.Product.UnitsInStock</dd>
            <dt class="col-sm-2">Units On Order:</dt><dd class="col-sm-10">@Model.Product.UnitsOnOrder</dd>
        </dl>
    </div>
    <div class="tab-pane fade" id="supplier" role="tabpanel" aria-labelledby="supplier-tab"></div>
    <div class="tab-pane fade" id="orders" role="tabpanel" aria-labelledby="orders-tab"></div>
</div>

The tabbed interface is generated by an unordered list (although you don't have to use a ul element) with the classes nav and nav-tabs applied. Each list item forms the actual tab, and the anchor element within the list item is used to generate the tab label, and to control selection. There are three tabs here, one of which has the active class applied to its anchor element. All this does is to apply a different style to the tab.

In this example, the data-toggle attribute is used to declaritively control switching between tabs. You could remove this attribute and write code to show and hide tabs if you prefer. The content is placed in divs with a tab-pane class inside a div with a tab-content class. This combination of classes is used to control visibility of the active tab content. The first tab pane also has fade, show and active classes. You use active to set the default tab. The fade class is used to animate the display of the content. The show class is used with fade to make the content visible by default. The content for the first tab is generated on the server and forms part of the initial view. The other tab panes are emtpy. They will be loaded on demand.

The easiest way to manage loading HTML content on demand is to use Partial results on the server and call them using AJAX. So the next step is to alter the PageModel by adding two new methods to generate the HTML for the two tabs:

public class TabsModel : PageModel
{
    private readonly IOrderService orderService;
    private readonly IProductService productService;
    private readonly ISupplierService supplierService;
    public TabsModel(IOrderService orderService, IProductService productService, ISupplierService supplierService)
    {
        this.orderService = orderService;
        this.productService = productService;
        this.supplierService = supplierService;
    }
 
    [BindProperty(SupportsGet = true)]
    public int ProductId { getset; } = 1;
    public Product Product { getset; }
    public async Task OnGetAsync()
    {
        Product = await productService.GetProductAsync(ProductId);
    }
 
    public async Task<PartialViewResult> OnGetSupplierAsync()
    {
        var supplier = await supplierService.GetSupplierForProduct(ProductId);
        return Partial("_SupplierDetails", supplier);
    }
        
    public async Task<PartialViewResult> OnGetOrdersAsync()
    {
        var details = await orderService.GetOrdersForProduct(ProductId);
        return Partial("_OrdersByProduct", details);
    }
}

OnGetSupplierAsync calls a service method that obtains details of a supplier from a database and then passes the data to a Partial page, returning the generated HTML in the response. The second method, OnGetOrdersAsync goes through the same process to obtain orders for the product. Here is the _OrdersByProduct partial:

@model List<OrderDetails>
 
<table class="table-sm table">
    <thead class="thead-light">
        <tr>
            <th>Customer</th>
            <th>Date</th>
            <th>Total Ordered</th>
            <th>Total Value</th>
        </tr>
    </thead>
    @foreach (var order in Model.OrderByDescending(o => o.Order.OrderDate))
    {
        <tr>
            <td>@order.Order.Customer.CompanyName</td>
            <td>@order.Order.OrderDate.ToShortDateString()</td>
            <td>@order.Quantity</td>
            <td>@(order.UnitPrice * order.Quantity)</td>
        </tr>
    }
</table>

Finally, here is a client side script that fires in response to shown.bs.tab, which is a custom Bootstrap jQuery event that fires after the tab has been shown:

@section scripts{
    <script>
        var supplierLoaded = false;
        var ordersLoaded = false;
        var productid = $('#Product_ProductId').val();
        $(function () {
            $('a[data-toggle="tab"]').on('shown.bs.tab'function (e) {
                switch ($(e.target).attr('aria-controls')) {
                    case "supplier":
                        if (!supplierLoaded) {
                            $('#supplier').load(`/tabs/supplier?productid=${productid}`)
                            supplierLoaded = true;
                        }
                        break;
                    case "orders":
                        if (!ordersLoaded) {
                            $('#orders').load(`/tabs/orders?productid=${productid}`)
                            ordersLoaded = true;
                        }
                        break;
                }
            });
        });
    </script>
}

The booleans declared at the beginning of the script are used to detect whether a specific tab has already been loaded or not. The aria-controls attribute of the tab that was clicked is used to determine which tab was clicked. If that tab has not already been loaded, the jQuery load function is used to call the correct named handler method for the tab, and to place the response of the AJAX call into the tab pane.

If you prefer to use plain JavaScript (i.e. no jQuery), here is the same fuctionality, using the Fetch API to make the AJAX call:

@section scripts{
    <script>
        var supplierLoaded = false;
        var ordersLoaded = false;
        var productid = document.getElementById('Product_ProductId').value;
        load = function (url, el) {
            fetch(url)
                .then((response) => {
                    return response.text();
                })
                .then((result) => {
                    document.getElementById(el).innerHTML = result;
                });
        }
        document.querySelectorAll('a[data-toggle="tab"]').forEach(el => el.addEventListener('click', (e) => {
            switch (e.target.getAttribute('aria-controls')) {
                case "supplier":
                    if (!supplierLoaded) {
                        load(`/tabs/supplier?productid=${productid}`'supplier');
                    }
                    supplierLoaded = true;
                    break;
                case "orders":
                    if (!ordersLoaded) {
                        load(`/tabs/supplier?productid=${productid}`'orders')
                        ordersLoaded = true;
                    }
                    break;
            }
        }));
    </script>
}

Note that the event handler is now the click event, not shown.bs.tab, because you can't use addEventListener with custom jQuery events.

Summary

This demo shows that you don't have to load all of the data for a complex UI in one go. You can use lazy loading to only load data if and when it is required. Bootstrap provides custom events that you can hook into when you use the jQuery approach to work with tabs. But it's just as easy to use a non-jQuery solution if you prefer.