Build a country and state dropdown in JavaScript
Load states, provinces, and regions after a country is selected, clear outdated choices, and keep a manual fallback when the lookup is unavailable.
On this page
Working example
HTML + JavaScriptBefore you start
Build two dependent dropdowns: the person selects a country, then chooses one of its subdivisions. The finished example is a single HTML file. It includes four sample country choices: the United States, Canada, the United Kingdom, and Australia. These choices demonstrate the form; they are not a statement about where your business ships.
Create a Public key on the keys page. Allow localhost and the production hostname where you will use the form. Replace YOUR_PUBLIC_API_KEY in the complete example with that key. Keep a secret key on your server.
Save the downloaded source as country-state-dropdown.html, then run this from its directory:
python3 -m http.server 8000Open the local example. Use the localhost hostname you allowed on the key, rather than opening the HTML file directly.
1. Start with the country
The country dropdown has an empty initial option. The region dropdown starts disabled. Nothing is fetched when the page loads; selecting a country starts its lookup.
<form id="location-form">
<div class="field">
<label for="country">Country</label>
<select id="country" name="country" autocomplete="country" required>
<option value="">Choose a country</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="GB">United Kingdom</option>
<option value="AU">Australia</option>
</select>
</div>
<div class="field">
<div id="state-list">
<label for="state">State, province, or region</label>
<select id="state" name="state" autocomplete="address-level1" aria-describedby="status" required disabled>
<option value="">Choose a country first</option>
</select>
</div>
<div id="manual-field" hidden>
<label for="state-manual">State, province, or region</label>
<input id="state-manual" name="state_manual" autocomplete="address-level1" maxlength="100" aria-describedby="status" disabled>
</div>
<div class="actions">
<button id="mode" class="quiet" type="button" disabled>Enter region manually</button>
<button id="retry" class="quiet" type="button" hidden>Retry lookup</button>
</div>
</div>
<p id="status" role="status" aria-live="polite" aria-atomic="true">The four countries are sample choices for this form.</p>
<button id="continue" type="submit" disabled>Continue</button>
<p id="confirmation" role="status" aria-live="polite"></p>
</form>Keep the option values separate from their labels. The country values are two-letter codes, such as CA for Canada. The second dropdown uses the subdivision's state code as its value and displays its name.
Label the second field State, province, or region. The response can include states, territories, provinces, council areas, and other subdivision types. The United Kingdom's list, for example, includes council areas and other subdivisions; it is not just England, Scotland, Wales, and Northern Ireland. Choose the geographic level your application needs when adapting this form.
2. Fetch the selected country's subdivisions
Call /country/{code}/states after the country changes. The complete example checks the country against its four allowed choices before creating the request.
async function lookupStates(code, signal) {
if (!API_KEY.startsWith('parse_public_')) {
throw new Error('Add your public API key and allow this hostname in its domain list.');
}
const url = new URL('https://api.parseapi.com/country/' + encodeURIComponent(code) + '/states');
url.search = new URLSearchParams({ key: API_KEY });
const response = await fetch(url, { signal });
if (!response.ok) {
const messages = {
401: 'Check your public API key.',
403: 'Check your public key and its allowed hostnames.',
404: 'No lookup is available for this country.',
429: 'The request limit was reached. Try again later.'
};
throw new Error(messages[response.status] || 'The lookup is unavailable. Try again later.');
}
return response.json();
}This is an operation of the Country API. It returns a country code and a states array. Each entry supplies the subdivision code, display name, and type.
Check the HTTP status before reading a result. A rejected key, unavailable lookup, or rate limit is a failed request. It does not mean that the selected country has no subdivisions. The example shows a retry action and allows manual entry after a failure.
3. Populate the second dropdown
Validate the response's country and array before adding options. A result for Canada must never populate a form currently set to Australia.
function fillStates(data, requestedCountry) {
if (!data || data.country !== requestedCountry || !Array.isArray(data.states) ||
data.states.some((item) => !item || typeof item.state !== 'string' ||
!item.state.trim() || typeof item.name !== 'string' || !item.name.trim()) ||
new Set(data.states.map((item) => item.state)).size !== data.states.length) {
throw new Error('The lookup returned an unexpected response.');
}
if (data.states.length === 0) {
setMode(true);
setStatus('No subdivisions were returned. Enter your region manually.');
return;
}
loadedFor = requestedCountry;
state.replaceChildren(
option('', 'Choose a state, province, or region'),
...data.states.map((item) => option(item.state, item.name + ' (' + item.state + ')'))
);
state.value = '';
setMode(false);
setStatus('Options loaded. Choose your region, or enter it manually.');
}Build each option with document.createElement('option'), then assign its label with textContent. This displays names as text, including punctuation or accents. It does not interpret a response value as HTML.
An empty states array is a valid empty result. The example switches to manual entry instead of reporting malformed data or inventing an option. Missing names, missing codes, duplicate codes, or a mismatched country are unexpected responses and keep the dropdown unavailable.
4. Clear stale country and state pairs
When the country changes, immediately clear the region selection, any manual region, and the local confirmation. Disable Continue until a new region is selected or entered. Keeping the old region while loading a new country could produce a mismatched pair.
The complete example uses both an AbortController and a request revision. Cancellation stops work that is no longer needed. The revision and country check ensure that a late response cannot replace newer options, even if it arrives after cancellation.
The same rule applies when someone chooses Enter region manually during a lookup. Switching modes cancels the request, so a late list cannot hide their input or replace what they type. Use dropdown is an explicit return to the lookup and clears the manual entry.
5. Keep manual entry distinct
Some forms need to accept a subdivision that is absent from their available list. The example allows that without pretending the text is a recognized code.
Continue shows a local object with country and either a selected state code or a state_manual string. The inactive value is null. Replace that local confirmation with your own submission handler. The demo does not send or save the selection.
Keep the country alongside a subdivision code. Short codes are not globally unique. Validate the submitted pair on your server, and decide how your application handles manual entries. Browser controls can be bypassed.
Test the changing states
Select Canada, choose a province, and click Continue. Change to Australia and confirm the old selection and confirmation disappear immediately. Change countries rapidly; only the final country's options should appear.
Start a lookup and immediately choose Enter region manually. Type a region and confirm a late response cannot switch you back to the dropdown. Then change the country and check that the manual region clears.
Try the lookup with your browser offline. You should see a failure message, Retry lookup, and the manual-entry option. Restore the connection and retry. Also check the valid empty-list case and a response whose country does not match the request when testing your own integration.
Next steps
Use ZIP autofill when a US postal code is already available, or suggest a country from an IP address while leaving the final choice with the visitor. The Country API and its reference explain the available lookup operations.
Complete example
One HTML file with the form, styles, and JavaScript. Replace YOUR_PUBLIC_API_KEY with your public key, then run it on a domain you have added to that key.
View and copy the complete source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Country and state dropdown</title>
<style>
:root { color-scheme: light dark; --bg: #fff; --ink: #152029; --muted: #5e6b75; --line: #d9e2e7; --field: #f6f9fa; --accent: #007c91; --button: #c5f6ff; --error: #b42335; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; }
}
:root[data-theme="dark"] { color-scheme: dark; --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; }
:root[data-theme="light"] { color-scheme: light; }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; padding: 24px; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
main { max-width: 540px; margin: 0 auto; }
h1 { margin: 0 0 6px; font-size: clamp(23px, 5vw, 30px); font-weight: 600; letter-spacing: -.035em; }
p { margin: 0; }
.intro, .note { color: var(--muted); }
form { margin-top: 24px; }
label { display: block; font-size: 13px; font-weight: 550; }
.field + .field { margin-top: 20px; }
select, input, button { font: inherit; }
select, input { display: block; width: 100%; min-width: 0; max-width: 100%; margin-top: 7px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 7px; background: var(--field); color: var(--ink); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
button { min-height: 46px; padding: 11px 16px; border: 0; border-radius: 7px; background: var(--button); color: #07313c; cursor: pointer; font-size: 14px; font-weight: 650; }
button:disabled, select:disabled { opacity: .55; cursor: default; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.quiet { background: transparent; color: var(--accent); padding-inline: 0; margin-right: 14px; font-weight: 500; }
#status { margin-top: 12px; min-height: 44px; color: var(--muted); font-size: 13px; }
#status[data-error="true"] { color: var(--error); }
#confirmation { margin-top: 16px; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.6 ui-monospace, monospace; }
.note { border-top: 1px solid var(--line); margin-top: 20px; padding-top: 16px; font-size: 12px; }
@media (max-width: 380px) { body { padding: 18px; } }
</style>
</head>
<body>
<main>
<h1>Country first. Region next.</h1>
<p class="intro">Choose a sample country to load its states, provinces, and other subdivisions.</p>
<!-- example:form -->
<form id="location-form">
<div class="field">
<label for="country">Country</label>
<select id="country" name="country" autocomplete="country" required>
<option value="">Choose a country</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="GB">United Kingdom</option>
<option value="AU">Australia</option>
</select>
</div>
<div class="field">
<div id="state-list">
<label for="state">State, province, or region</label>
<select id="state" name="state" autocomplete="address-level1" aria-describedby="status" required disabled>
<option value="">Choose a country first</option>
</select>
</div>
<div id="manual-field" hidden>
<label for="state-manual">State, province, or region</label>
<input id="state-manual" name="state_manual" autocomplete="address-level1" maxlength="100" aria-describedby="status" disabled>
</div>
<div class="actions">
<button id="mode" class="quiet" type="button" disabled>Enter region manually</button>
<button id="retry" class="quiet" type="button" hidden>Retry lookup</button>
</div>
</div>
<p id="status" role="status" aria-live="polite" aria-atomic="true">The four countries are sample choices for this form.</p>
<button id="continue" type="submit" disabled>Continue</button>
<p id="confirmation" role="status" aria-live="polite"></p>
</form>
<!-- /example:form -->
<p class="note">Subdivision types vary by country. This demo shows a selection locally; it does not submit or validate a shipping address.</p>
</main>
<script>
// Use a public key restricted to your development and production hostnames.
const API_KEY = 'YOUR_PUBLIC_API_KEY';
const form = document.querySelector('#location-form');
const country = document.querySelector('#country');
const state = document.querySelector('#state');
const manual = document.querySelector('#state-manual');
const stateList = document.querySelector('#state-list');
const manualField = document.querySelector('#manual-field');
const mode = document.querySelector('#mode');
const retry = document.querySelector('#retry');
const proceed = document.querySelector('#continue');
const status = document.querySelector('#status');
const confirmation = document.querySelector('#confirmation');
const countries = new Set(['US', 'CA', 'GB', 'AU']);
let controller;
let revision = 0;
let loadedFor = '';
let manualMode = false;
function setStatus(message, error = false) {
status.textContent = message;
status.dataset.error = String(error);
}
function cancelRequest() {
revision += 1;
controller?.abort();
controller = undefined;
stateList.setAttribute('aria-busy', 'false');
}
function option(value, label) {
const element = document.createElement('option');
element.value = value;
element.textContent = label;
return element;
}
function setMode(useManual) {
manualMode = useManual;
manualField.hidden = !useManual;
manual.disabled = !useManual;
manual.required = useManual;
stateList.hidden = useManual;
state.disabled = useManual || !loadedFor || loadedFor !== country.value;
state.required = !useManual;
mode.textContent = useManual ? 'Use dropdown' : 'Enter region manually';
}
function canContinue() {
return countries.has(country.value) && (manualMode
? manual.value.trim().length > 0
: loadedFor === country.value && !!state.value &&
Array.from(state.options).some((item) => item.value === state.value));
}
function clearSelection() {
loadedFor = '';
manual.value = '';
state.replaceChildren(option('', 'Choose a country first'));
state.value = '';
confirmation.textContent = '';
proceed.disabled = true;
retry.hidden = true;
setMode(false);
}
// example:request
async function lookupStates(code, signal) {
if (!API_KEY.startsWith('parse_public_')) {
throw new Error('Add your public API key and allow this hostname in its domain list.');
}
const url = new URL('https://api.parseapi.com/country/' + encodeURIComponent(code) + '/states');
url.search = new URLSearchParams({ key: API_KEY });
const response = await fetch(url, { signal });
if (!response.ok) {
const messages = {
401: 'Check your public API key.',
403: 'Check your public key and its allowed hostnames.',
404: 'No lookup is available for this country.',
429: 'The request limit was reached. Try again later.'
};
throw new Error(messages[response.status] || 'The lookup is unavailable. Try again later.');
}
return response.json();
}
// /example:request
// example:result
function fillStates(data, requestedCountry) {
if (!data || data.country !== requestedCountry || !Array.isArray(data.states) ||
data.states.some((item) => !item || typeof item.state !== 'string' ||
!item.state.trim() || typeof item.name !== 'string' || !item.name.trim()) ||
new Set(data.states.map((item) => item.state)).size !== data.states.length) {
throw new Error('The lookup returned an unexpected response.');
}
if (data.states.length === 0) {
setMode(true);
setStatus('No subdivisions were returned. Enter your region manually.');
return;
}
loadedFor = requestedCountry;
state.replaceChildren(
option('', 'Choose a state, province, or region'),
...data.states.map((item) => option(item.state, item.name + ' (' + item.state + ')'))
);
state.value = '';
setMode(false);
setStatus('Options loaded. Choose your region, or enter it manually.');
}
// /example:result
async function loadCountry() {
// Invalidate the old country/region pair before starting another request.
cancelRequest();
clearSelection();
const requestedCountry = country.value;
mode.disabled = !countries.has(requestedCountry);
if (mode.disabled) {
setStatus('Choose one of the sample countries.');
return;
}
const current = revision;
controller = new AbortController();
const request = controller;
let timedOut = false;
const timeout = setTimeout(() => { timedOut = true; request.abort(); }, 10000);
state.replaceChildren(option('', 'Loading options...'));
stateList.setAttribute('aria-busy', 'true');
setStatus('Loading regions for the selected country...');
try {
const data = await lookupStates(requestedCountry, request.signal);
if (current !== revision || country.value !== requestedCountry) return;
fillStates(data, requestedCountry);
} catch (error) {
if (current !== revision || country.value !== requestedCountry) return;
state.replaceChildren(option('', 'Options unavailable'));
retry.hidden = false;
const message = timedOut ? 'The lookup timed out.'
: error instanceof TypeError ? 'Could not reach parseAPI.'
: error instanceof SyntaxError ? 'The response could not be read.'
: error.message;
setStatus(message + ' Retry the lookup or enter your region manually.', true);
} finally {
clearTimeout(timeout);
if (current === revision) {
controller = undefined;
stateList.setAttribute('aria-busy', 'false');
proceed.disabled = !canContinue();
}
}
}
country.addEventListener('change', loadCountry);
retry.addEventListener('click', loadCountry);
mode.addEventListener('click', () => {
if (!countries.has(country.value)) return;
if (manualMode) { void loadCountry(); return; }
cancelRequest();
clearSelection();
setMode(true);
setStatus('Enter the region as you want it saved. This entry is not checked against the list.');
manual.focus();
});
for (const field of [state, manual]) {
field.addEventListener(field === state ? 'change' : 'input', () => {
confirmation.textContent = '';
proceed.disabled = !canContinue();
});
}
form.addEventListener('submit', (event) => {
event.preventDefault();
if (!canContinue() || !form.reportValidity()) return;
const selection = {
country: country.value,
state: manualMode ? null : state.value,
state_manual: manualMode ? manual.value.trim() : null
};
confirmation.textContent = 'Ready for your form:\n' + JSON.stringify(selection, null, 2);
});
</script>
</body>
</html>
Need a key? Create a public API key. For every field and parameter, see Country API reference.