All articles

Cascading Dropdowns with JsonResult in ASP.NET Core

Build cascading dropdowns in ASP.NET Core — a JsonResult action per level, the fetch API on the change event, safe option rendering, and a jQuery version for older projects.

0 · log in to like, save & follow Share on LinkedIn Share on X

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.

cover

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.
Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.