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.

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-itemssources 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 (
RedirectToActionafter 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)
No comments yet — be the first to share your thoughts.