MVC: Two Different Methods of Producing Cascading Drop Downs

Cascading drop downs is a feature that we see in many different applications across the web. A common scenario is asking a user to enter in their State and City.

Features like these are such common place that they are almost always expected in a web-based application. The following code sample will walk you through two different ways of producing this effect if you are using .NET Core MVC as the web development framework.

This sample application uses some JSON files that contain a listing of Cities and States. It then uses an in-memory entity framework database for temporary storage of the selected States and Cities.

The Create and Edit pages have both been included in this example since the cascading effects are different before it is known what a user is going to select vs knowing what the user has selected.

The code for this sample can be found in this GitHub repository:

Method 1

Description

The first method demonstrated on the page takes advantage of server-side rendering. It will render all the possible values for all Cities and all States all at once. After the page is rendered, JavaScript reads the data from the drop downs and stores all the possible values in a JavaScript array. Then after a state is selected it will only show available values for the Cities that were made in the array.

Pros

One call to the Create / Edit page retrieves all the information necessary to perform the cascading actions. No additional calls to the server for additional lists of Cities after a State is selected.

Cons

This method can produce waste. The server must load all the possible values for every combination of every City and State before the page is rendered. The values are also then stored client side in JavaScript arrays. While this can be faster in some scenarios. Where there is greater amount of data the benefit might not be shown.

Code Highlights

When using this method all of the states and cities are rendered in to the select list by setting them as view bags for the Create and Edit pages.

        // GET: SelectedCityStates/Create
        public IActionResult Create()
        {
            SelectedCityState selectedCityStateToEdit = new SelectedCityState();

            SetStateCityViewBags();
            return View(selectedCityStateToEdit);
        }

        // GET: SelectedCityStates/Edit/5
        public async Task<IActionResult> Edit(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var selectedCityState = await _context.SelectedCityStates.FindAsync(id);
            if (selectedCityState == null)
            {
                return NotFound();
            }
            SetStateCityViewBags();
            return View(selectedCityState);
        }

        private void SetStateCityViewBags()
        {
            string statesTextFile = System.IO.File.ReadAllText("states.json");
            string citiesTextFile = System.IO.File.ReadAllText("cities.json");

            List<USState> usStates = JsonSerializer.Deserialize<List<USState>>(statesTextFile, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }).OrderBy(o => o.Name).ToList();
            List<USCity> usCities = JsonSerializer.Deserialize<List<USCity>>(citiesTextFile, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }).OrderBy(o => o.State).ThenBy(o => o.City).ToList();

            ViewBag.USStates = usStates;
            ViewBag.USCities = usCities;
        }
The view then takes the information from the view bag and loops over all of the values given and puts them in the select list that they belong to.
@model MVCCascadingDropDowns.Models.SelectedCityState
@using MVCCascadingDropDowns.Models;

<div asp-validation-summary="ModelOnly" class="text-danger"></div>
@if(Model != null)
{
    <input type="hidden" asp-for="Id" />
}
<div class="form-group">
    <label asp-for="State" class="control-label"></label>
    <select name="State" id="State" class="form-control">
        @if (string.IsNullOrEmpty(Model.State))
        {
            <option value="" selected>Please Select</option>
        }
        else
        {
            <option value="">Please Select</option>
        }
        @foreach (USState usState in (List<USState>)ViewBag.USStates)
        {
            if (Model.State == usState.Name)
            {
                <option value="@usState.Name" selected>@usState.Name</option>
            }
            else
            {
                <option value="@usState.Name">@usState.Name</option>
            }
        }
    </select>
    <span asp-validation-for="State" class="text-danger"></span>
</div>
<div class="form-group">
    <label asp-for="City" class="control-label"></label>
    <select name="City" id="City" class="form-control">
        @if (string.IsNullOrEmpty(Model.City))
        {
            <option value="" selected>Please Select</option>
        }
        else
        {
            <option value="">Please Select</option>
        }
        @foreach (USCity usCity in (List<USCity>)ViewBag.USCities)
        {
            if (Model.State == usCity.State &&  Model.City == usCity.City)
            {
                <option value="@usCity.City" data-state="@usCity.State" selected>@usCity.State - @usCity.City</option>
            }
            else
            {
                <option value="@usCity.City" data-state="@usCity.State">@usCity.State - @usCity.City</option>
            }
        }
    </select>
    <span asp-validation-for="City" class="text-danger"></span>
</div>
<div class="form-group">
    <input type="submit" value="Save" class="btn btn-primary" />
</div>
After the page loads then this JavaScript takes over. We start by creating two empty arrays for states and cities, we then populate the states and cities arrays. Filter the Cities if a State has already been selected (in the case of an edit page). Then set up the State drop down to listen to changes, and re-filter the cities select list.
$(() => {

    var states = [];
    var cities = [];

    var stateSelectList = $('#State');
    var citySelectList = $('#City');

    var selectedState = stateSelectList.val();
    var selectedCity = citySelectList.val();

    populateStatesArray();
    populateCitiesArray();
    filterCities();

    function populateStatesArray() {
        stateSelectList.find('option').each((index, element) => {
            states.push(element.value);
        });
    }

    function populateCitiesArray() {
        citySelectList.find('option').each((index, element) => {
            cities.push({
                state: element.getAttribute('data-state'),
                city: element.value
            });
        });
    }

    function filterCities() {
        citySelectList.prop('options').length = 1;
        if (selectedState !== '') {
            for (var i = 0; i < cities.length; i++) {
                if (cities[i].state == selectedState) {
                    var selectThisItem = (cities[i].city === selectedCity);
                    var opt = document.createElement('option');
                    opt.setAttribute('data-state', cities[i].state);
                    opt.setAttribute('value', cities[i].city);
                    opt.appendChild(document.createTextNode(cities[i].city));

                    if (selectThisItem) {
                        opt.setAttribute('selected', true);
                    }

                    citySelectList.append(opt);

                }
            }
        }
    }

    stateSelectList.on('change', (e) => {
        selectedState = stateSelectList.val();

        filterCities();
    });

});

Method 2

Description

The second method demonstrated on the page takes advantage of AJAX calls. Two empty select lists are rendered by the server, but they have no data until after the page loads. After the page loads it will retrieve a list of States. After a State is selected, an additional call to the server requesting the relevant Cities, and then the Cities drop down list is populated based on the results returned by the server.

Pros

The page loads, before the list of States is retrieved. States, and Cities are not stored in JavaScript arrays on the client, so it takes up less memory on the client / browser.

Cons

It makes your application “chatty”. There is not much of a delay between selecting a State and the list of Cities appearing, but if the complexity of the data to be cascaded isn’t very complex then it may be better off storing them client side and letting the client pull the appropriate values without having to go back out to the server to retrieve the relevant values.

Code Highlights

The controller will have two extra methods on it to get states list and get cities list. The Create and Edit Views no longer supply ViewBags

        // GET: SelectedCityStatesAjax/Create
        public IActionResult Create()
        {
            SelectedCityState selectedCityStateToEdit = new SelectedCityState();

            return View(selectedCityStateToEdit);
        }
        // GET: SelectedCityStatesAjax/Edit/5
        public async Task<IActionResult> Edit(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var selectedCityState = await _context.SelectedCityStates.FindAsync(id);
            if (selectedCityState == null)
            {
                return NotFound();
            }
            return View(selectedCityState);
        }

        public async Task<JsonResult> GetStatesList()
        {
            string statesTextFile = await System.IO.File.ReadAllTextAsync("states.json");
            List<USState> usStates = JsonSerializer.Deserialize<List<USState>>(statesTextFile, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }).OrderBy(o => o.Name).ToList();

            return Json(usStates, new JsonSerializerOptions { DictionaryKeyPolicy = JsonNamingPolicy.CamelCase });
        }

        public async Task<JsonResult> GetCitiesList([FromQuery]string state)
        {
            string citiesTextFile = await System.IO.File.ReadAllTextAsync("cities.json");
            List<USCity> usCities = JsonSerializer.Deserialize<List<USCity>>(citiesTextFile, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }).Where(w => w.State == state).OrderBy(o => o.State).ThenBy(o => o.City).ToList();

            return Json(usCities, new JsonSerializerOptions { DictionaryKeyPolicy = JsonNamingPolicy.CamelCase });
        }

Finally, after the server has rendered the view, JavaScript takes over. The states list is populated immediately. After a State is selected then an additional ajax call is made to retrieve the Cities list. Then a listener is setup to fetch and render a new list of cities any time a new State is selected.

$(() => {
    var stateSelectList = $('#State');
    var citySelectList = $('#City');

    populateStatesDropDown();    

    function populateStatesDropDown() {
        $.get(
            '/SelectedCityStatesAjax/GetStatesList', (states) => {

                for (var s = 0; s < states.length; s++) {
                    var opt = document.createElement('option');
                    opt.setAttribute('value', states[s]['Name']);
                    opt.appendChild(document.createTextNode(states[s]['Name']));

                    if (states[s]['Name'] === stateSelectList.attr('data-initial-val')) {
                        opt.setAttribute('selected', true);
                    }

                    stateSelectList.append(opt);
                }

                if (stateSelectList.val() !== '') {
                    populateCitiesDropDown();
                }
            }
        );
    }

    function populateCitiesDropDown() {
        $.get(
            `/SelectedCityStatesAjax/GetCitiesList?state=${stateSelectList.val()}`, (cities) => {

                for (var c = 0; c < cities.length; c++) {
                    var opt = document.createElement('option');
                    opt.setAttribute('value', cities[c]['City']);
                    opt.appendChild(document.createTextNode(cities[c]['City']));

                    if (cities[c]['City'] === citySelectList.attr('data-initial-val')) {
                        opt.setAttribute('selected', true);
                    }

                    citySelectList.append(opt);
                }
            }
        )
    }

    stateSelectList.on('change', (e) => {
        citySelectList.prop('options').length = 1;

        if (e.currentTarget.value !== '') {
            populateCitiesDropDown();
        }
    });
    
});

Conclusion

Both methods are good options depending on the use case and complexity of the data. Some analysis and test may be needed to determine which is best for the situation.

About Intertech

Founded in 1991, Intertech delivers software development consulting to Fortune 500, Government, and Leading Technology institutions, along with real-world based corporate education services to current consulting customers. Whether you are a company looking to partner with a team of technology leaders who provide solutions, mentor staff and add true business value, or a developer interested in working for a company that invests in its employees, we’d like to meet you. Learn more about us.