ASP.NET

Cascading Country, State and City DropDownLists in ASP.NET without a full page refresh

ASP.NET · · Published 2026-10-20

Dependent lists are a classic Web Forms task: choosing a country fills the list of states, choosing a state fills the cities. The server-side version takes a handful of lines. Wrapping it in an UpdatePanel keeps the same code but refreshes only the lists, so the page does not flash or jump back to the top.

The data

CREATE TABLE dbo.Countries (CountryId int PRIMARY KEY, Name nvarchar(80) NOT NULL);
CREATE TABLE dbo.States    (StateId int PRIMARY KEY, CountryId int NOT NULL REFERENCES dbo.Countries,
                            Name nvarchar(80) NOT NULL);
CREATE TABLE dbo.Cities    (CityId int PRIMARY KEY, StateId int NOT NULL REFERENCES dbo.States,
                            Name nvarchar(80) NOT NULL);

INSERT dbo.Countries VALUES (1, N'Australia'), (2, N'India');
INSERT dbo.States VALUES (10, 1, N'Queensland'), (11, 1, N'Victoria'), (20, 2, N'Kerala'), (21, 2, N'Punjab');
INSERT dbo.Cities VALUES (100, 10, N'Brisbane'), (101, 10, N'Cairns'), (110, 11, N'Melbourne'),
                         (200, 20, N'Kochi'), (201, 20, N'Thiruvananthapuram'), (210, 21, N'Amritsar');

The markup

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Address.aspx.cs" Inherits="Demo.Address" %>
<!DOCTYPE html>
<html>
<head runat="server"><title>Address</title></head>
<body>
<form id="form1" runat="server">
    <asp:ScriptManager ID="sm" runat="server" />

    <asp:UpdatePanel ID="upLists" runat="server" UpdateMode="Conditional">
        <ContentTemplate>
            <p>
                <asp:Label runat="server" AssociatedControlID="ddlCountry" Text="Country" />
                <asp:DropDownList ID="ddlCountry" runat="server" AutoPostBack="true"
                    OnSelectedIndexChanged="ddlCountry_SelectedIndexChanged" />
            </p>
            <p>
                <asp:Label runat="server" AssociatedControlID="ddlState" Text="State" />
                <asp:DropDownList ID="ddlState" runat="server" AutoPostBack="true"
                    OnSelectedIndexChanged="ddlState_SelectedIndexChanged" />
            </p>
            <p>
                <asp:Label runat="server" AssociatedControlID="ddlCity" Text="City" />
                <asp:DropDownList ID="ddlCity" runat="server" />
            </p>
        </ContentTemplate>
    </asp:UpdatePanel>

    <asp:Button ID="btnSave" runat="server" Text="Save" OnClick="btnSave_Click" />
    <asp:Label ID="lblResult" runat="server" />
</form>
</body>
</html>

The code-behind

using System;
using System.Configuration;
using System.Data;
using System.Data.SqlClient;
using System.Web.UI;
using System.Web.UI.WebControls;

namespace Demo
{
    public partial class Address : Page
    {
        private static string ConnStr =>
            ConfigurationManager.ConnectionStrings["Db"].ConnectionString;

        protected void Page_Load(object sender, EventArgs e)
        {
            if (!IsPostBack)
            {
                Fill(ddlCountry, "SELECT CountryId, Name FROM dbo.Countries ORDER BY Name", null, "-- country --");
                Reset(ddlState, "-- state --");
                Reset(ddlCity, "-- city --");
            }
        }

        protected void ddlCountry_SelectedIndexChanged(object sender, EventArgs e)
        {
            Reset(ddlCity, "-- city --");
            if (ddlCountry.SelectedValue == "")
            {
                Reset(ddlState, "-- state --");
                return;
            }
            Fill(ddlState, "SELECT StateId, Name FROM dbo.States WHERE CountryId = @id ORDER BY Name",
                 int.Parse(ddlCountry.SelectedValue), "-- state --");
        }

        protected void ddlState_SelectedIndexChanged(object sender, EventArgs e)
        {
            if (ddlState.SelectedValue == "")
            {
                Reset(ddlCity, "-- city --");
                return;
            }
            Fill(ddlCity, "SELECT CityId, Name FROM dbo.Cities WHERE StateId = @id ORDER BY Name",
                 int.Parse(ddlState.SelectedValue), "-- city --");
        }

        private static void Fill(DropDownList list, string sql, int? parentId, string prompt)
        {
            var table = new DataTable();
            using (var con = new SqlConnection(ConnStr))
            using (var cmd = new SqlCommand(sql, con))
            {
                if (parentId.HasValue) cmd.Parameters.Add("@id", SqlDbType.Int).Value = parentId.Value;
                using (var da = new SqlDataAdapter(cmd)) da.Fill(table);
            }
            list.Items.Clear();
            list.DataSource = table;
            list.DataTextField = "Name";
            list.DataValueField = table.Columns[0].ColumnName;
            list.DataBind();
            list.Items.Insert(0, new ListItem(prompt, ""));
            list.Enabled = table.Rows.Count > 0;
        }

        private static void Reset(DropDownList list, string prompt)
        {
            list.Items.Clear();
            list.Items.Add(new ListItem(prompt, ""));
            list.Enabled = false;
        }

        protected void btnSave_Click(object sender, EventArgs e)
        {
            if (ddlCity.SelectedValue == "")
            {
                lblResult.Text = "Please choose a city.";
                return;
            }
            lblResult.Text = "Saved city id " + ddlCity.SelectedValue + ".";
        }
    }
}

How the cascade works

  1. On the first request, only the country list is filled; the others hold a prompt and are disabled.
  2. AutoPostBack="true" makes the browser post the form as soon as the country changes. ASP.NET restores the lists from view state, compares the posted value with the previous one and raises SelectedIndexChanged.
  3. The handler refills the state list for that country and resets the city list, because a city from the previous country would no longer make sense.
  4. Choosing a state does the same for cities.

Two details make this robust. The prompt item is inserted after DataBind(), because binding replaces the items (unless AppendDataBoundItems is true). And the lists are filled only in event handlers or on the first load: refilling them in every Page_Load would erase the user's selection before the event could be raised.

What the UpdatePanel changes

Without the ScriptManager and UpdatePanel, every selection reloads the whole page. With them, the AutoPostBack becomes an asynchronous postback: the browser sends the form in the background, the full page life cycle runs on the server exactly as before, and only the HTML inside the panel is sent back and replaced.

Event validation and client-side alternatives

You could fill the child lists entirely in JavaScript from a JSON endpoint. If you do, the posted value of the state or city list is one the server never rendered, and event validation rejects it with "Invalid postback or callback argument". Options:

Whatever the approach, check on save that the city really belongs to the chosen state. The browser can post any combination.

Accessibility notes

Auto-posting lists change the page while the user is still interacting, which can disorient keyboard and screen-reader users (WCAG "On Input" criterion). With an UpdatePanel focus usually stays on the list, which helps. On pages where accessibility is critical, consider a short "Update states" button next to the country list instead of AutoPostBack.

Variations

Official documentation