Improved Cascading Dropdowns With Blazor

In my last article, I looked at the experimental Blazor framework, using Cascading Dropdown Lists as an entry point. Since then, some feedback from Steve Sanderson to a question I asked has shown me a better way to implement this pattern.

In the original example, the selectedIndex of the dependent select element was retained when it was populated with new data as a result of the primary selection being changed. Although not obvious to me at the time (more on that a bit later), this is the default behaviour of the browser. The selectedIndex is reset to -1 if it points to an element that no longer exists or if the select element's value attribute is set to null. This is usually achieved by clearing the dropdown of options (e.g. by using the jQuery empty() function) when the primary dropdown's onchange event fires.

The following code shows how to achieve this by building on the example I provided previously. The original data and model stay the same. The changes are only made to the Blazor component:

@using Blazor.Shared
@page "/booksbind"
@inject HttpClient http

<h1>Books</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (authors == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <select id="authors" onchange="@AuthorSelectionChanged">
        <option></option>
        @foreach (var author in authors)
        {
            <option value="@author.AuthorId">@author.Name</option>
        }
    </select>
}
@if (books != null)
{
    <select id="books" value=@selectedBook?.BookId onchange="@BookSelectionChanged">
        <option value="0"></option>
        @foreach (var book in books)
        {
            <option value="@book.BookId">@book.Title</option>
        }
    </select>
}
@if (selectedBook != null)
{
    <div>
        Title: @selectedBook.Title<br />
        Year published: @selectedBook.YearPublished<br />
        Price: @selectedBook.Price
    </div>
}


@functions {

    Author[] authors;
    Book[] books;
    Book selectedBook;

    protected override async Task OnInitAsync()
    {
        authors = await http.GetJsonAsync<Author[]>("/api/book");
    }

    void AuthorSelectionChanged(UIChangeEventArgs e)
    {
        books = null;
        selectedBook = null;
        if (int.TryParse(e.Value.ToString(), out int id))
        {
            books = authors.First(a => a.AuthorId == id).Books.ToArray();
        }
    }

    void BookSelectionChanged(UIChangeEventArgs e)
    {
        if (int.TryParse(e.Value.ToString(), out int id))
        {
            selectedBook = books.FirstOrDefault(b => b.BookId == id);
        }
        else
        {
            selectedBook = null;
        }
    }
}

The key changes to this code are the introduction of a value attribute on the dependent dropdown which takes the selected book's key as a value, and a selectedBook variable of type Book representing the selected book. Now, when the author's selection has changed, the collection of books is set to null as is the selected book, resulting in the options being cleared and the selectedIndex of the book dropdown being reset to -1.

And this works nicely, but Steve then went on to illustrate a much cleaner approach to managing this by taking advantage of Blazor's two-way databinding capability. As he said:

"If it was me, I wouldn't want to be using the onchange events, parsing ints, and generally relying on the DOM to track the selected index of the dropdowns. Instead of all that, I'd prefer to model the selections in C# and use Blazor's two-way bindings to sync with the DOM".

So here's the improved approach that Steve provided:

@using Blazor.Shared
@page "/books"
@inject HttpClient http

<h1>Books</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (authors == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <select bind="SelectedAuthorId">
        <option value=@(0)></option>
        @foreach (var author in authors)
        {
            <option value="@author.AuthorId">@author.Name</option>
        }
    </select>
}

@if (SelectedAuthorId != default)
{
    var books = authors.Single(x => x.AuthorId == SelectedAuthorId).Books;

    <select bind="SelectedBookId">
        <option value=@(0)></option>
        @foreach (var book in books)
        {
            <option value="@book.BookId">@book.Title</option>
        }
    </select>

    var selectedBook = books.FirstOrDefault(x => x.BookId == SelectedBookId);
    @if (selectedBook != null)
    {
        <div>
            Title: @selectedBook.Title<br />
            Year published: @selectedBook.YearPublished<br />
            Price: @selectedBook.Price
        </div>
    }
}

@functions {
    Author[] authors;

    // Track the selected author ID, and when it's written to, reset SelectedBookId
    int _selectedAuthorId;
    int SelectedAuthorId
    {
        get => _selectedAuthorId;
        set
        {
            _selectedAuthorId = value;
            SelectedBookId = default;
        }
    }

    int SelectedBookId { get; set; }

    protected override async Task OnInitAsync()
    {
        authors = await http.GetJsonAsync<Author[]>("/api/book");
    }
}

The functions block is a lot shorter. There are no onchange event handlers. Instead, the Blazor bind attribute is used to, ermm... bind the SelectedAuthorId property to the author's dropdown. This essentially applies the value of the bound property to the value attribute of the select element, and wires it to an onchange handler ensuring that the property value changes when the selection changes.

There is also a property representing the selected book's key value. This is set to null (default) in the SelectedAuthorId's setter. It is bound to the Books dropdown, so when the author selection is changed, the value of the Books dropdown is set to null, resting the selectedIndex to -1.

Summary

Steve's intervention provides a very welcome introduction to the databinding capability within Blazor, but it also highlights something else. My original approach is very much what you might expect from someone who has become too reliant on jQuery - hooking up to event handlers and potholing (spelunking) around the DOM. It's easy to forget basic underlying stuff (like how the selectedIndex behaves) when you are immersed in abstractions.

This is a topic that I will be revisiting in the very near future.