Build a cascading select
A country → state → city picker in React, including the bundle-size decision that comes with it.
The most common use of this dataset: three dependent dropdowns where each one narrows the next.
The logic is four lines. The decision that actually matters is where the city data lives, so start there.
Choose where cities come from
The browser build imports the compact city dataset statically, so importing the package anywhere in client code puts roughly 11 MB of JSON into that bundle. Countries and states are small; cities are not.
| Approach | Cost | Use when |
|---|---|---|
| Import the package in client code | ~11 MB in the client bundle | Internal tools, offline apps, extensions |
import() the package on demand | Same bytes, but a separate chunk fetched on first use | You need offline data but not on first paint |
| Fetch cities from the hosted API | A few KB per request | Public sites where first-load size matters |
Do not import the package at module scope in a page that must load fast. Bundlers cannot tree-shake the city payload, because the entry reads it directly.
Countries and states only
If your form stops at state level, import the two published JSON subpaths instead of the main browser entry. This avoids pulling the city and district payloads into the bundle.
import { useMemo, useState } from 'react';
import countriesData from '@tansuasici/country-state-city/data/countries.json';
import statesData from '@tansuasici/country-state-city/data/states.json';
import type { Country, State } from '@tansuasici/country-state-city';
const countries = countriesData as Country[];
const allStates = statesData as State[];
export function CountryStatePicker() {
const [countryId, setCountryId] = useState<number | null>(null);
const states = useMemo(
() => (countryId ? allStates.filter((state) => state.countryId === countryId) : []),
[countryId]
);
return (
<>
<select onChange={(event) => setCountryId(Number(event.target.value) || null)}>
<option value="">Select a country</option>
{countries.map((country) => (
<option key={country.id} value={country.id}>
{country.emoji} {country.name}
</option>
))}
</select>
<select disabled={!states.length}>
<option value="">Select a state</option>
{states.map((state) => (
<option key={state.id} value={state.id}>
{state.name}
</option>
))}
</select>
</>
);
}The JSON modules are exported package entry points, so this does not rely on an internal file path. The casts restore the package types after JSON-module inference.
Adding cities
Keeps the client bundle small. Each step is a request, so track loading state.
import { useEffect, useState } from 'react';
function useCities(countryCode: string | null, subdivisionCode: string | null) {
const [cities, setCities] = useState<Array<{ id: number; name: string }>>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!countryCode || !subdivisionCode) {
setCities([]);
return;
}
const controller = new AbortController();
setLoading(true);
const query = new URLSearchParams({
country: countryCode,
subdivision: subdivisionCode,
limit: '100',
});
fetch(`/api/v1/places?${query}`, { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
return response.json();
})
.then((body) => setCities(body.data))
.catch((error) => {
if (error.name !== 'AbortError') setCities([]);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [countryCode, subdivisionCode]);
return { cities, loading };
}Aborting on change matters here: without it, a fast click through three states can resolve out of order and leave the previous state's cities on screen.
Call the API from your own server route so the key stays server-side, and page through results — the default page size is 25 and the maximum is 100. See Limits & policy and Errors.
Reset children when a parent changes
The bug every cascading select ships at least once: picking a new country leaves the old state selected, and the form submits a state that does not belong to it.
function handleCountryChange(nextCountryId: number | null) {
setCountryId(nextCountryId);
setStateId(null); // clear the child
setCityId(null); // and the grandchild
}Deriving the child lists from the parent — as useMemo does above — keeps the options correct,
but the selected values are separate state and must be cleared explicitly.
Türkiye
Türkiye has an explicit district layer. For an address form, districts are usually what people expect to pick, not the populated-place list.
const districts = CountryStateCity.getDistrictsByStateId(2170); // IstanbulSee Upgrading to v3 for how districts and legacy city records relate.