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.