All articles

How to Bind a DropDownList in ASP.NET Core MVC

Bind selects with the select tag helper: model-bound options, enum dropdowns, required validation, and cascading dropdowns using a JSON action and fetch.

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

Dropdowns look trivial until you need one bound to a model property, validated, populated from a database, driven by an enum, or cascading from another dropdown. This guide covers all of those for ASP.NET Core MVC on .NET 10, using the <select> tag helper — the modern replacement for Html.DropDownList — with working code verified against the companion repository.

How to Bind a DropDownList in ASP.NET Core MVC

The model

We'll build an employee registration form with three selects: country (from a list), city (cascading, loaded from the selected country), and department (from an enum).

public enum Department { Engineering, Marketing, Finance, HumanResources }

public class EmployeeForm
{
    [Required, StringLength(60)]
    public string Name { get; set; } = "";

    [Required(ErrorMessage = "Pick a country.")]
    public string? CountryCode { get; set; }

    [Required(ErrorMessage = "Pick a department.")]
    public Department? Department { get; set; }

    public int? CityId { get; set; }
}

Two details worth copying: the bound properties are nullable (string?, Department?) so an untouched select posts null and [Required] fires with your message instead of a binder error; and each [Required] carries a human-friendly message.

Populating the options

The controller supplies the country options — in a real app this is a database query, here a static list keeps the sample focused:

public class DropdownController : Controller
{
    private static readonly List<SelectListItem> Countries =
    [
        new("India", "IN"), new("United States", "US"), new("Germany", "DE"),
    ];

    public IActionResult Index()
    {
        ViewBag.Countries = Countries;
        return View(new EmployeeForm());
    }

    [HttpPost, ValidateAntiForgeryToken]
    public IActionResult Index(EmployeeForm form)
    {
        ViewBag.Countries = Countries;          // repopulate before re-rendering!
        if (!ModelState.IsValid) return View(form);
        TempData["Saved"] = $"{form.Name} — {form.Department} ({form.CountryCode})";
        return RedirectToAction(nameof(Index));
    }
}

The classic bug lives in the POST action: when validation fails and you re-render the form, the options must be repopulated — they are not part of the posted data. Forgetting this produces an empty dropdown on every validation error.

Binding with the select tag helper

<select asp-for="CountryCode" asp-items="ViewBag.Countries" class="form-select" id="country">
    <option value="">— pick a country —</option>
</select>
<span asp-validation-for="CountryCode" class="text-danger"></span>

asp-for wires the name, id, and selected value to the model property; asp-items renders the options. The blank first <option> is what makes [Required] meaningful — without it the browser preselects the first real option and the field can never be invalid.

On a round trip, the tag helper automatically re-selects the user's previous choice from ModelState — no manual Selected = true bookkeeping, which was most of the pain in classic MVC.

Bind a DropDownList from a ViewModel (and from the database)

The pattern most searches ask for: the options live in the view model, sourced from the database, no ViewBag anywhere.

public class EmployeeFormVm
{
    public EmployeeForm Form { get; set; } = new();
    public IReadOnlyList<SelectListItem> Countries { get; set; } = [];
}

public async Task<IActionResult> Index(CancellationToken ct)
{
    var vm = new EmployeeFormVm
    {
        Countries = await db.Countries
            .OrderBy(c => c.Name)
            .Select(c => new SelectListItem(c.Name, c.Code))
            .ToListAsync(ct),
    };
    return View(vm);
}
@model EmployeeFormVm

<select asp-for="Form.CountryCode" asp-items="Model.Countries" class="form-select">
    <option value="">— pick a country —</option>
</select>

Three details make this the production-grade version: the EF query projects directly to SelectListItem (only two columns leave the database); asp-for="Form.CountryCode" binds into the nested form object, so the POST action receives EmployeeFormVm with the selection populated; and on validation failure you re-run the same options query before re-rendering — same repopulation rule as the ViewBag version, but now the compiler checks every property the view touches.

Enum dropdowns

For enums, skip building SelectListItems entirely:

<select asp-for="Department" asp-items="Html.GetEnumSelectList<Department>()" class="form-select">
    <option value="">— pick a department —</option>
</select>

GetEnumSelectList renders one option per enum member. To show "Human Resources" instead of "HumanResources", decorate the member with [Display(Name = "Human Resources")].

Cascading dropdowns

The city list depends on the chosen country, so the options load on demand from a small JSON endpoint:

private static readonly Dictionary<string, string[]> CitiesByCountry = new()
{
    ["IN"] = ["Pune", "Bengaluru", "Hyderabad"],
    ["US"] = ["Austin", "Seattle"],
    ["DE"] = ["Berlin", "Munich"],
};

[HttpGet]
public IActionResult Cities(string country) =>
    Json(CitiesByCountry.TryGetValue(country ?? "", out var cities) ? cities : []);

Verified output:

GET /Dropdown/Cities?country=IN
["Pune","Bengaluru","Hyderabad"]

The view refills the second select with a few lines of plain fetch — no jQuery needed:

<script>
    document.getElementById('country').addEventListener('change', async e => {
        const res = await fetch(`/Dropdown/Cities?country=${e.target.value}`);
        const cities = await res.json();
        const city = document.getElementById('city');
        city.innerHTML = cities.length
            ? cities.map((c, i) => `<option value="${i + 1}">${c}</option>`).join('')
            : '<option value="">— pick a country first —</option>';
    });
</script>

ViewBag vs. view model for options

ViewBag keeps the sample short, but it is stringly-typed and invisible to the compiler. For anything beyond a demo, prefer a view model that carries both the selection and the options:

public class EmployeeFormVm
{
    public EmployeeForm Form { get; set; } = new();
    public IReadOnlyList<SelectListItem> Countries { get; set; } = [];
}

The trade-off is a little more mapping code in exchange for refactor-safe views. Either way, the repopulate-on-POST rule stands.

Checklist

  • Nullable bound property + [Required] + blank first option = clean required validation.
  • Repopulate asp-items sources in the POST action before returning the view.
  • Html.GetEnumSelectList<T>() for enums; [Display] for friendly labels.
  • Cascade with a tiny JSON action and fetch — return arrays, not HTML.
  • Post/Redirect/Get (RedirectToAction after a successful save) so refresh doesn't resubmit.

The complete form — with client-side validation, the cascading pair, and the enum select — runs as-is from the companion repository.

Comments (0)

Log in to join the conversation.

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