Cascading location selects are a staple of checkout pages, sign-up flows and shipping forms: the user picks a country, the state list loads for that country, then the city list loads for that state. In this guide you will build that component in Angular 19 with a standalone component, HttpClient, reactive forms and signals, backed by the CountryDataAPI select endpoints. If you work in React, the React country selector guide covers the single-select version.
What You Will Build
- Three native
<select>elements with real<label for>associations - Each child select stays disabled until its parent has a value and its options have loaded
- Loading, empty and error states announced through an
aria-liveregion, with a retry button - An in-memory cache so revisiting a country does not trigger (or bill) a second request
You need an Angular 19 project, Node.js 18.19 or later, and an API key. Create an account to get one; paid plans start at €5/month with 200,000 monthly tokens (see pricing).
The Three Endpoints
The select endpoints are designed for dropdowns. They return only what an <option> needs, and each request costs a fixed 1 token no matter how many items come back:
GET /v1/select/countries?apikey=KEY&lang=enreturns every country as{ id, name, code, phone_code, flag }GET /v1/select/states?apikey=KEY&country=COUNTRY_IDreturns{ id, name }for each state or province (docs)GET /v1/select/cities?apikey=KEY&state=STATE_ID&country=COUNTRY_IDreturns{ id, name }for each city (docs).countryis optional but helps when two states share a name.
Successful responses look like { "success": true, "data": [...], "count": 52, "tokens_used": 1 }. Two failure shapes matter for the UI: an authentication or quota problem (invalid key, no tokens left) comes back as { "error": true, "message": "..." }, and an unknown country or state returns { "success": false, "data": [] }. Both arrive with an HTTP 200 status, so the code has to check the body, not only the status code.
The lang parameter accepts en, es, pt, fr, de and it; anything else falls back to English.
Why not the general endpoints?
You could use states by country and cities by state instead, but those charge per item (1 token per state, 1 token per 5 cities), and their cost tier depends on the fields you request. If you do use them, keep fields to the basic set, for example fields=id,lang,state_name. For a dropdown, the select endpoints are cheaper and already return only the fields you need.
Step 1: Configure HttpClient and the API Settings
Register HttpClient in app.config.ts:
// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), provideHttpClient(withFetch())],
};
Angular 19 no longer generates environment files by default; run ng generate environments, then add the settings:
// src/environments/environment.development.ts
export const environment = {
countryApiBase: 'https://api.countrydataapi.com/v1/select',
countryApiKey: 'YOUR_API_KEY',
};
// src/environments/environment.ts (production)
export const environment = {
countryApiBase: '/api/locations', // your own proxy, see below
countryApiKey: '',
};
Anything in an Angular bundle is readable by anyone who opens DevTools. For production, send requests through a small server proxy that adds the key on the server. The plain JavaScript guide includes a complete Express proxy that works with this component unchanged. In either case, keep real keys out of any repository you publish.
Step 2: A Cached Location Service
The service wraps the three endpoints, turns the API's body-level errors into real errors, and caches each observable with shareReplay. A failed request is removed from the cache so the next attempt tries again.
// src/app/location/location.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, catchError, map, shareReplay, throwError } from 'rxjs';
import { environment } from '../../environments/environment';
export interface SelectOption {
id: string;
name: string;
}
export interface CountryOption extends SelectOption {
code: string;
phone_code: string;
flag: string;
}
interface SelectResponse<T> {
success?: boolean;
data?: T[];
count?: number;
tokens_used?: number;
error?: boolean;
message?: string;
}
@Injectable({ providedIn: 'root' })
export class LocationService {
private readonly http = inject(HttpClient);
private readonly cache = new Map<string, Observable<unknown[]>>();
private readonly lang = 'en';
getCountries(): Observable<CountryOption[]> {
return this.fetch<CountryOption>('countries', {});
}
getStates(countryId: string): Observable<SelectOption[]> {
return this.fetch<SelectOption>('states', { country: countryId });
}
getCities(stateId: string, countryId: string): Observable<SelectOption[]> {
return this.fetch<SelectOption>('cities', { state: stateId, country: countryId });
}
private fetch<T>(resource: string, query: Record<string, string>): Observable<T[]> {
const cacheKey = `${resource}:${this.lang}:${JSON.stringify(query)}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached as Observable<T[]>;
}
let params = new HttpParams({ fromObject: { ...query, lang: this.lang } });
if (environment.countryApiKey) {
params = params.set('apikey', environment.countryApiKey);
}
const request$ = this.http
.get<SelectResponse<T>>(`${environment.countryApiBase}/${resource}`, { params })
.pipe(
map((body) => {
if (body.error) {
throw new Error(body.message ?? 'The location service rejected the request.');
}
if (!body.success) {
throw new Error('Nothing was found for this selection.');
}
return body.data ?? [];
}),
catchError((err: unknown) => {
this.cache.delete(cacheKey);
return throwError(() => err);
}),
shareReplay(1),
);
this.cache.set(cacheKey, request$);
return request$;
}
}
Notice what is missing: no debounceTime. A select emits one change per deliberate choice, unlike a text input that fires on every keystroke, so debouncing would only add latency. What you do need is switchMap, so that a slow response for a country the user has already moved away from is discarded.
Step 3: The Standalone Component
The form has three controls. The state and city controls start disabled, and disabled controls are excluded from form validation, so a country with no states still produces a valid form without extra logic.
// src/app/location/location-picker.component.ts
import { Component, DestroyRef, WritableSignal, computed, inject, output, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { EMPTY, catchError, filter, switchMap, tap } from 'rxjs';
import { CountryOption, LocationService, SelectOption } from './location.service';
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
export interface LocationSelection {
country: CountryOption | undefined;
state: SelectOption | undefined;
city: SelectOption | undefined;
}
@Component({
selector: 'app-location-picker',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './location-picker.component.html',
})
export class LocationPickerComponent {
private readonly locations = inject(LocationService);
private readonly destroyRef = inject(DestroyRef);
readonly selected = output<LocationSelection>();
readonly form = inject(FormBuilder).nonNullable.group({
country: [{ value: '', disabled: true }, Validators.required],
state: [{ value: '', disabled: true }, Validators.required],
city: [{ value: '', disabled: true }, Validators.required],
});
readonly countries = signal<CountryOption[]>([]);
readonly states = signal<SelectOption[]>([]);
readonly cities = signal<SelectOption[]>([]);
readonly countriesState = signal<LoadState>('idle');
readonly statesState = signal<LoadState>('idle');
readonly citiesState = signal<LoadState>('idle');
readonly errorMessage = signal('');
readonly busy = computed(() =>
[this.countriesState(), this.statesState(), this.citiesState()].includes('loading'),
);
readonly statusText = computed(() => {
if (this.countriesState() === 'loading') return 'Loading countries...';
if (this.statesState() === 'loading') return 'Loading states...';
if (this.citiesState() === 'loading') return 'Loading cities...';
if (this.statesState() === 'ready' && this.states().length === 0) {
return 'No states or provinces are listed for this country.';
}
if (this.citiesState() === 'ready' && this.cities().length === 0) {
return 'No cities are listed for this state.';
}
return '';
});
constructor() {
const { country, state, city } = this.form.controls;
this.loadCountries();
country.valueChanges
.pipe(
tap(() => {
this.clear(state, this.states, this.statesState);
this.clear(city, this.cities, this.citiesState);
}),
filter((id) => id !== ''),
tap(() => this.statesState.set('loading')),
switchMap((id) =>
this.locations.getStates(id).pipe(
catchError((err) => this.fail(this.statesState, err)),
),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((list) => {
this.states.set(list);
this.statesState.set('ready');
if (list.length > 0) state.enable({ emitEvent: false });
});
state.valueChanges
.pipe(
tap(() => this.clear(city, this.cities, this.citiesState)),
filter((id) => id !== ''),
tap(() => this.citiesState.set('loading')),
switchMap((id) =>
this.locations.getCities(id, country.value).pipe(
catchError((err) => this.fail(this.citiesState, err)),
),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((list) => {
this.cities.set(list);
this.citiesState.set('ready');
if (list.length > 0) city.enable({ emitEvent: false });
});
}
retry(): void {
const { country, state } = this.form.controls;
if (this.countriesState() === 'error') this.loadCountries();
else if (this.statesState() === 'error') country.updateValueAndValidity();
else if (this.citiesState() === 'error') state.updateValueAndValidity();
}
submit(): void {
if (this.form.invalid || this.busy()) return;
const { country, state, city } = this.form.getRawValue();
this.selected.emit({
country: this.countries().find((c) => c.id === country),
state: this.states().find((s) => s.id === state),
city: this.cities().find((c) => c.id === city),
});
}
private loadCountries(): void {
this.errorMessage.set('');
this.countriesState.set('loading');
this.locations
.getCountries()
.pipe(
catchError((err) => this.fail(this.countriesState, err)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((list) => {
this.countries.set(list);
this.countriesState.set('ready');
this.form.controls.country.enable({ emitEvent: false });
});
}
private clear(
control: FormControl<string>,
list: WritableSignal<SelectOption[]>,
state: WritableSignal<LoadState>,
): void {
control.reset('', { emitEvent: false });
control.disable({ emitEvent: false });
list.set([]);
state.set('idle');
this.errorMessage.set('');
}
private fail(state: WritableSignal<LoadState>, err: unknown) {
state.set('error');
this.errorMessage.set(
err instanceof HttpErrorResponse
? `Could not reach the location service (status ${err.status}).`
: err instanceof Error
? err.message
: 'Something went wrong.',
);
return EMPTY;
}
}
Two details are worth pointing out. First, reset and disable use emitEvent: false, so clearing the city does not set off another round of change handlers. Second, retrying calls updateValueAndValidity() on the parent control, which emits its current value again and re-runs the same pipeline. Because failed requests were removed from the cache, the retry really goes back to the network.
Step 4: An Accessible Template
Each select has a visible label linked with for/id, aria-busy while its options load, and a pointer to a shared status region. Errors go to a role="alert" element so screen readers announce them straight away.
<!-- src/app/location/location-picker.component.html -->
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<div class="field">
<label for="country">Country</label>
<select id="country" formControlName="country"
[attr.aria-busy]="countriesState() === 'loading'"
aria-describedby="location-status">
<option value="">
{{ countriesState() === 'loading' ? 'Loading countries...' : 'Select a country' }}
</option>
@for (c of countries(); track c.id) {
<option [value]="c.id">{{ c.name }}</option>
}
</select>
</div>
<div class="field">
<label for="state">State / province</label>
<select id="state" formControlName="state"
[attr.aria-busy]="statesState() === 'loading'"
aria-describedby="location-status">
<option value="">
{{ statesState() === 'loading' ? 'Loading states...' : 'Select a state' }}
</option>
@for (s of states(); track s.id) {
<option [value]="s.id">{{ s.name }}</option>
}
</select>
</div>
<div class="field">
<label for="city">City</label>
<select id="city" formControlName="city"
[attr.aria-busy]="citiesState() === 'loading'"
aria-describedby="location-status">
<option value="">
{{ citiesState() === 'loading' ? 'Loading cities...' : 'Select a city' }}
</option>
@for (c of cities(); track c.id) {
<option [value]="c.id">{{ c.name }}</option>
}
</select>
</div>
<p id="location-status" role="status" aria-live="polite">{{ statusText() }}</p>
<div role="alert">
@if (errorMessage()) {
<p>{{ errorMessage() }}</p>
<button type="button" (click)="retry()">Try again</button>
}
</div>
<button type="submit" [disabled]="form.invalid || busy()">Continue</button>
</form>
Do not bind [disabled] on the selects themselves. With reactive forms, the control's enable() and disable() methods are the single source of truth, and Angular warns if you mix the two. The submit button is a plain button, so binding [disabled] there is fine. It also checks busy(), because while a child list is loading, its control is still disabled and the form briefly counts as valid.
The response includes a flag emoji for each country. It is tempting to put it in the option text, but screen readers read it out ("flag: Spain, Spain"), so the template leaves it out. Use it in a custom visual picker if you have one.
Step 5: Use It in a Page
// src/app/checkout/checkout.component.ts
import { Component } from '@angular/core';
import { LocationPickerComponent, LocationSelection } from '../location/location-picker.component';
@Component({
selector: 'app-checkout',
standalone: true,
imports: [LocationPickerComponent],
template: `
<h1>Shipping address</h1>
<app-location-picker (selected)="onLocation($event)" />
`,
})
export class CheckoutComponent {
onLocation(selection: LocationSelection): void {
console.log(selection.country?.code, selection.state?.name, selection.city?.name);
}
}
Store the IDs and the names you received. The code field on the country holds the ISO 3166-1 alpha-2 code, which is what most payment and shipping providers expect. See the ISO country codes guide for details.
Caching and Token Usage
A user who completes the form triggers at most three requests: one for countries, one for the states of the chosen country and one for the cities of the chosen state. That is at most three tokens. The service cache means switching back to a country already viewed costs nothing, and every LocationPickerComponent in the app shares the same country list.
To keep data across page reloads, persist the country list in localStorage with a timestamp and reuse it for a day or more, since country lists rarely change. If you use Angular SSR with provideClientHydration(), the HTTP transfer cache also stops the browser from repeating the country request that already ran on the server. You can check your balance at any time with the remaining tokens endpoint.
Common Pitfalls
- Using
mergeMapinstead ofswitchMap. WithmergeMap, every response is applied in whatever order it arrives. A user who clicks quickly through countries can end up looking at the states of the wrong one. - Forgetting to reset the city when the country changes. The state handler clears the city, but only if the state value actually changes. That is why the country handler clears both children explicitly.
- Treating HTTP 200 as success. Quota and key problems arrive as
200witherror: true. If you only rely onHttpClient's error channel, the component will show an empty list instead of a useful message. - Hard-coding the country name. The
countryparameter also accepts a name, but names change withlang. Always pass theidfrom the previous response. - Subscribing without cleanup.
takeUntilDestroyedends thevalueChangessubscriptions when the component is destroyed. Without it, a picker inside a dialog keeps listening after the dialog closes.
Testing the Service
Because all HTTP work sits in LocationService, the component is easy to test with a stub service that returns of([...]) or throwError(...). For the service itself, use provideHttpClientTesting() and HttpTestingController. Check that a second call to getCountries() makes no new request, and that a response with error: true reaches the subscriber's error callback. These two tests protect the behaviors that are easiest to break during a refactor: caching, and the body-level error check.
Next Steps
- Pass the user's language to the service so names come back in
es,pt,fr,deorit - Add a postal code field and validate it with the address form validation guide
- Building the same form in another stack? See the Vue 3 version or the plain JavaScript version
Ready to wire it up? Get your API key and check the select endpoint reference.