International address forms are notoriously difficult to get right. A static hardcoded list of countries and states quickly becomes outdated and fails international users. The solution is to build dynamic cascading dropdowns powered by a live API. This guide shows you how to build a complete country → state → city form using CountryDataAPI, with examples for React, Vue, and vanilla JavaScript.
The Cascading Form Pattern
The pattern is straightforward:
- Load all countries on page mount
- When the user selects a country, load its states
- When the user selects a state, load its cities
- Optionally, when the user enters a zip code, auto-fill city and state
API Endpoints Used
We'll use three CountryDataAPI endpoints:
GET /v1/countries/all— All countriesGET /v1/states/by-country?country=US— States for a countryGET /v1/cities/by-state?country=US&state=NY— Cities for a state
React Implementation
The API Service
First, create a centralized API service:
// services/geoApi.js
const BASE_URL = 'https://api.countrydataapi.com/v1';
const API_KEY = process.env.REACT_APP_COUNTRY_API_KEY;
const headers = { 'x-api-key': API_KEY };
export const geoApi = {
getCountries: () =>
fetch(`${BASE_URL}/countries/all`, { headers }).then(r => r.json()),
getStatesByCountry: (countryCode) =>
fetch(`${BASE_URL}/states/by-country?country=${countryCode}`, { headers })
.then(r => r.json()),
getCitiesByState: (countryCode, stateCode) =>
fetch(`${BASE_URL}/cities/by-state?country=${countryCode}&state=${stateCode}`, { headers })
.then(r => r.json()),
};
The Address Form Component
import React, { useState, useEffect } from 'react';
import { geoApi } from './services/geoApi';
const AddressForm = ({ onSubmit }) => {
const [countries, setCountries] = useState([]);
const [states, setStates] = useState([]);
const [cities, setCities] = useState([]);
const [address, setAddress] = useState({
country: '',
state: '',
city: '',
street: '',
zipCode: ''
});
const [loading, setLoading] = useState({
countries: true,
states: false,
cities: false
});
// Load countries on mount
useEffect(() => {
geoApi.getCountries().then(data => {
setCountries(data.sort((a, b) => a.name.localeCompare(b.name)));
setLoading(prev => ({ ...prev, countries: false }));
});
}, []);
// Load states when country changes
useEffect(() => {
if (!address.country) {
setStates([]);
setCities([]);
return;
}
setLoading(prev => ({ ...prev, states: true }));
setAddress(prev => ({ ...prev, state: '', city: '' }));
geoApi.getStatesByCountry(address.country).then(data => {
setStates(data.sort((a, b) => a.name.localeCompare(b.name)));
setLoading(prev => ({ ...prev, states: false }));
});
}, [address.country]);
// Load cities when state changes
useEffect(() => {
if (!address.state || !address.country) {
setCities([]);
return;
}
setLoading(prev => ({ ...prev, cities: true }));
setAddress(prev => ({ ...prev, city: '' }));
geoApi.getCitiesByState(address.country, address.state).then(data => {
setCities(data.sort((a, b) => a.name.localeCompare(b.name)));
setLoading(prev => ({ ...prev, cities: false }));
});
}, [address.state]);
const handleChange = (field) => (e) => {
setAddress(prev => ({ ...prev, [field]: e.target.value }));
};
return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit(address); }}>
<div className="form-row">
<label>Country *</label>
<select
value={address.country}
onChange={handleChange('country')}
disabled={loading.countries}
required
>
<option value="">
{loading.countries ? 'Loading...' : '-- Select Country --'}
</option>
{countries.map(c => (
<option key={c.iso2} value={c.iso2}>{c.name}</option>
))}
</select>
</div>
<div className="form-row">
<label>State / Province</label>
<select
value={address.state}
onChange={handleChange('state')}
disabled={!address.country || loading.states}
>
<option value="">
{loading.states ? 'Loading...' : '-- Select State --'}
</option>
{states.map(s => (
<option key={s.state_code} value={s.state_code}>
{s.name}
</option>
))}
</select>
</div>
<div className="form-row">
<label>City</label>
<select
value={address.city}
onChange={handleChange('city')}
disabled={!address.state || loading.cities}
>
<option value="">
{loading.cities ? 'Loading...' : '-- Select City --'}
</option>
{cities.map(c => (
<option key={c.name} value={c.name}>{c.name}</option>
))}
</select>
</div>
<div className="form-row">
<label>Street Address *</label>
<input
type="text"
value={address.street}
onChange={handleChange('street')}
required
/>
</div>
<button type="submit">Save Address</button>
</form>
);
};
Vue 3 Implementation
<template>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label>Country</label>
<select v-model="address.country" @change="onCountryChange">
<option value="">{{ countriesLoading ? 'Loading...' : 'Select Country' }}</option>
<option v-for="c in countries" :key="c.iso2" :value="c.iso2">
{{ c.name }}
</option>
</select>
</div>
<div class="form-group">
<label>State</label>
<select v-model="address.state" :disabled="!address.country" @change="onStateChange">
<option value="">{{ statesLoading ? 'Loading...' : 'Select State' }}</option>
<option v-for="s in states" :key="s.state_code" :value="s.state_code">
{{ s.name }}
</option>
</select>
</div>
<div class="form-group">
<label>City</label>
<select v-model="address.city" :disabled="!address.state">
<option value="">{{ citiesLoading ? 'Loading...' : 'Select City' }}</option>
<option v-for="c in cities" :key="c.name" :value="c.name">
{{ c.name }}
</option>
</select>
</div>
</form>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
const API_KEY = import.meta.env.VITE_COUNTRY_API_KEY;
const BASE_URL = 'https://api.countrydataapi.com/v1';
const fetchGeo = (path) =>
fetch(`${BASE_URL}${path}`, { headers: { 'x-api-key': API_KEY } }).then(r => r.json());
const address = reactive({ country: '', state: '', city: '' });
const countries = ref([]);
const states = ref([]);
const cities = ref([]);
const countriesLoading = ref(true);
const statesLoading = ref(false);
const citiesLoading = ref(false);
onMounted(async () => {
countries.value = (await fetchGeo('/countries/all')).sort((a, b) => a.name.localeCompare(b.name));
countriesLoading.value = false;
});
const onCountryChange = async () => {
address.state = ''; address.city = '';
states.value = []; cities.value = [];
if (!address.country) return;
statesLoading.value = true;
states.value = (await fetchGeo(`/states/by-country?country=${address.country}`))
.sort((a, b) => a.name.localeCompare(b.name));
statesLoading.value = false;
};
const onStateChange = async () => {
address.city = ''; cities.value = [];
if (!address.state) return;
citiesLoading.value = true;
cities.value = (await fetchGeo(`/cities/by-state?country=${address.country}&state=${address.state}`))
.sort((a, b) => a.name.localeCompare(b.name));
citiesLoading.value = false;
};
</script>
Accessibility Best Practices
International address forms must be accessible:
- Add
aria-busy="true"to selects while loading - Use
aria-labelor<label>for all inputs - Announce state changes to screen readers with
aria-live="polite" - Support keyboard navigation fully
Performance Optimization
For high-traffic apps, pre-load all countries at app startup and cache states/cities in memory:
// Cache strategy
const geoCache = {};
const fetchWithCache = async (url) => {
if (geoCache[url]) return geoCache[url];
const data = await fetch(url, { headers: { 'x-api-key': API_KEY } }).then(r => r.json());
geoCache[url] = data;
return data;
};
Next Steps
You now have a full cascading address form. To extend it further:
- Add zip code auto-fill with the zip code lookup guide
- Add phone number fields using
phone_codefrom the countries API - Validate the address against the zip code endpoint

