A cascading dropdown loads its options based on what was picked in another dropdown — choose a category, see only that category's products. The pattern is two pieces: a JsonResult action that returns children for a parent id, and a few lines of JavaScript that call it when the first dropdown changes. This tutorial builds it with the browser's built-in fetch API; a jQuery version is included at the end for older projects.

The controller
One action for each level of the cascade:
public class CatalogController : Controller
{
private static readonly Dictionary<int, string[]> ProductsByCategory = new()
{
[1] = ["Galaxy A15 Mobile", "Pixel 9", "iPhone 16"],
[2] = ["Air Pods", "Sony WH-1000XM5", "JBL Flip"],
[3] = ["Pen drive", "SSD 1TB", "MicroSD 256GB"],
};
public IActionResult Index() => View();
public JsonResult Categories() =>
Json(new[]
{
new { id = 1, name = "Mobiles" },
new { id = 2, name = "Audio" },
new { id = 3, name = "Storage" },
});
public JsonResult Products(int categoryId) =>
Json(ProductsByCategory.TryGetValue(categoryId, out var products) ? products : []);
}
In a real application the dictionary is a database query — with EF Core, a Select on the products table filtered by categoryId (see the Web API + EF tutorial). The shape stays the same: parent id in, children out as JSON.
Verified responses:
GET /catalog/categories → [{"id":1,"name":"Mobiles"}, ...]
GET /catalog/products?categoryId=2 → ["Air Pods","Sony WH-1000XM5","JBL Flip"]
The view
Two selects; the second starts disabled:
<label>Category
<select id="category">
<option value="">— pick a category —</option>
</select>
</label>
<label>Product
<select id="product" disabled>
<option value="">— pick a category first —</option>
</select>
</label>
The JavaScript
<script>
const categorySelect = document.getElementById('category');
const productSelect = document.getElementById('product');
function fillOptions(select, items, placeholder, getValue = x => x, getText = x => x) {
select.innerHTML = '';
select.append(new Option(placeholder, ''));
for (const item of items)
select.append(new Option(getText(item), getValue(item)));
}
// load the first dropdown on page load
(async function () {
const categories = await (await fetch('/catalog/categories')).json();
fillOptions(categorySelect, categories, '— pick a category —', c => c.id, c => c.name);
})();
// cascade on change
categorySelect.addEventListener('change', async function () {
if (!this.value) {
productSelect.disabled = true;
fillOptions(productSelect, [], '— pick a category first —');
return;
}
const products = await (await fetch(`/catalog/products?categoryId=${this.value}`)).json();
fillOptions(productSelect, products, '— pick a product —');
productSelect.disabled = false;
});
</script>
That's the whole feature. The helper fillOptions clears and refills a select using the built-in Option constructor — no string-built HTML, so option text is safe whatever the data contains. Add a third level (product → variants) by repeating the same change-listener pattern on productSelect.
The jQuery version
Same endpoints, jQuery syntax — for pages that already load jQuery:
$('#category').on('change', function () {
const categoryId = $(this).val();
const product = $('#product').empty().prop('disabled', !categoryId);
product.append($('<option>').val('').text('— pick a product —'));
if (!categoryId) return;
$.getJSON('/catalog/products', { categoryId }, function (products) {
products.forEach(p => product.append($('<option>').text(p)));
});
});
Common pitfalls
- Second dropdown keeps stale options — always clear it (
innerHTML = ''/.empty()) before refilling, and reset it when the first dropdown returns to the placeholder. - Selected value lost after postback — this pattern avoids full postbacks entirely; if you submit the form, repopulate and reselect from the model on load.
- Slow queries fire per change — debounce isn't needed for selects (unlike autocomplete), but do index the FK column your children query filters on.
Comments (0)
No comments yet — be the first to share your thoughts.