Tutorial

Vue 3 Country, State and City Dropdown with a Composable

September 16, 2026 11 min read CountryDataAPI Team

Cascading country, state and city selects in Vue 3 with script setup and a reusable composable. Handles stale responses, empty lists, errors and caching.

A country, state and city picker looks simple until you handle everything around it: a child list that has to reset when its parent changes, slow responses that arrive out of order, countries with no subdivisions, and screen reader users who need to know something is loading. This guide builds that picker in Vue 3 with the Composition API and <script setup>. All the logic lives in a reusable composable, so the component itself stays small. For React, see the React country selector guide.

Prerequisites

  • A Vue 3.3+ project created with Vite (npm create vue@latest)
  • A CountryDataAPI key. Register to get one; paid plans start at €5/month with 200,000 monthly tokens (pricing)

How the Data Flows

The picker uses the three select endpoints, which exist specifically for dropdowns:

  • /v1/select/countries returns { id, name, code, phone_code, flag } for every country
  • /v1/select/states?country=COUNTRY_ID returns { id, name } for each state or province (reference)
  • /v1/select/cities?state=STATE_ID&country=COUNTRY_ID returns { id, name } for each city. The country parameter is optional and helps when two states share a name (reference).

Every request needs the apikey query parameter and accepts an optional lang (en, es, pt, fr, de, it). Each call consumes a flat 1 token, whether a country has 5 states or 50. The id of each item is the value you pass to the next request.

A successful body looks like { "success": true, "data": [...], "count": 17, "tokens_used": 1 }. If the key is invalid or your account has no tokens left, you get { "error": true, "message": "..." }. If the country or state is not found, you get { "success": false, "data": [] }. Both errors come back with HTTP 200, so the client has to inspect the body.

Why not states by country or cities by country? Those general-purpose endpoints charge per item returned, and their cost depends on the fields you request. If you need them for something else, ask only for the basic fields (for example fields=id,lang,state_name). For a dropdown, the select endpoints return exactly the fields you need, at the lowest cost.

Step 1: Environment Variables

# .env.local  (never commit this file)
VITE_COUNTRY_API_BASE=https://api.countrydataapi.com/v1/select
VITE_COUNTRY_API_KEY=your_api_key_here

Vite inlines every VITE_ variable into the JavaScript bundle, so these values are public once you deploy. That is fine for local development. In production, point VITE_COUNTRY_API_BASE at a server route that adds the key, and leave VITE_COUNTRY_API_KEY empty. The plain JavaScript guide has a ready-made Express proxy with the same URL shape. Also add .env.local to .gitignore (Vite's template already ignores *.local).

Step 2: A Small API Module with Caching

This module owns the HTTP details. It caches the promise rather than the result, so two components that ask for countries at the same moment share a single request. If a request fails, it is removed from the cache so a retry goes back to the network.

// src/api/countryData.js
const BASE_URL = import.meta.env.VITE_COUNTRY_API_BASE || 'https://api.countrydataapi.com/v1/select';
const API_KEY = import.meta.env.VITE_COUNTRY_API_KEY || '';

const cache = new Map();

export function fetchOptions(resource, query = {}, lang = 'en') {
  const params = new URLSearchParams({ ...query, lang });
  const cacheKey = `${resource}?${params}`;
  if (cache.has(cacheKey)) {
    return cache.get(cacheKey);
  }
  if (API_KEY) {
    params.set('apikey', API_KEY);
  }

  const request = fetch(`${BASE_URL}/${resource}?${params}`)
    .then(async (response) => {
      if (!response.ok) {
        throw new Error(`The location service answered with status ${response.status}.`);
      }
      const body = await response.json();
      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 ?? [];
    })
    .catch((err) => {
      cache.delete(cacheKey);
      throw err;
    });

  cache.set(cacheKey, request);
  return request;
}

Step 3: The Composable

useOptionList handles one select's options and loading status. It uses a request counter so that a slow response for a country the user has already left is ignored. useLocationSelect connects three of those lists with watchers.

// src/composables/useLocationSelect.js
import { computed, onMounted, ref, watch } from 'vue';
import { fetchOptions } from '../api/countryData';

function useOptionList(resource) {
  const items = ref([]);
  const status = ref('idle'); // 'idle' | 'loading' | 'ready' | 'error'
  const error = ref('');
  let lastQuery = {};
  let requestId = 0;

  async function load(query = {}) {
    const current = ++requestId;
    lastQuery = query;
    items.value = [];
    error.value = '';
    status.value = 'loading';
    try {
      const data = await fetchOptions(resource, query);
      if (current !== requestId) return; // a newer request replaced this one
      items.value = data;
      status.value = 'ready';
    } catch (err) {
      if (current !== requestId) return;
      error.value = err.message;
      status.value = 'error';
    }
  }

  function clear() {
    requestId++;
    items.value = [];
    error.value = '';
    status.value = 'idle';
  }

  const reload = () => load(lastQuery);

  return { items, status, error, load, clear, reload };
}

export function useLocationSelect() {
  const countryId = ref('');
  const stateId = ref('');
  const cityId = ref('');

  const countries = useOptionList('countries');
  const states = useOptionList('states');
  const cities = useOptionList('cities');

  onMounted(() => countries.load());

  watch(countryId, (id) => {
    stateId.value = '';
    cityId.value = '';
    states.clear();
    cities.clear();
    if (id) states.load({ country: id });
  });

  watch(stateId, (id) => {
    cityId.value = '';
    cities.clear();
    if (id) cities.load({ state: id, country: countryId.value });
  });

  const lists = [countries, states, cities];
  const isLoading = computed(() => lists.some((l) => l.status.value === 'loading'));
  const errorMessage = computed(() => lists.find((l) => l.error.value)?.error.value ?? '');

  const statusMessage = computed(() => {
    if (countries.status.value === 'loading') return 'Loading countries...';
    if (states.status.value === 'loading') return 'Loading states...';
    if (cities.status.value === 'loading') return 'Loading cities...';
    if (states.status.value === 'ready' && states.items.value.length === 0) {
      return 'No states or provinces are listed for this country.';
    }
    if (cities.status.value === 'ready' && cities.items.value.length === 0) {
      return 'No cities are listed for this state.';
    }
    return '';
  });

  // Countries without states (or states without cities) are still complete.
  const isComplete = computed(() => {
    if (!countryId.value || states.status.value !== 'ready') return false;
    if (states.items.value.length === 0) return true;
    if (!stateId.value || cities.status.value !== 'ready') return false;
    return cities.items.value.length === 0 || cityId.value !== '';
  });

  const selection = computed(() => ({
    country: countries.items.value.find((c) => c.id === countryId.value) ?? null,
    state: states.items.value.find((s) => s.id === stateId.value) ?? null,
    city: cities.items.value.find((c) => c.id === cityId.value) ?? null,
  }));

  function retry() {
    lists.find((l) => l.status.value === 'error')?.reload();
  }

  // Return flat refs so templates unwrap them automatically.
  return {
    countryId, stateId, cityId,
    countryOptions: countries.items, countriesStatus: countries.status,
    stateOptions: states.items, statesStatus: states.status,
    cityOptions: cities.items, citiesStatus: cities.status,
    isLoading, errorMessage, statusMessage, isComplete, selection, retry,
  };
}

The flat return object matters. Vue templates only unwrap top-level refs, so returning countries.items nested inside another object would force .value into your markup.

Keeping the fetch logic out of the component also makes testing easier. You can mock fetchOptions with Vitest, mount the component, and check the empty, loading and error states without any network traffic or token usage.

There is no debounce here, and there should not be. A select fires one change per deliberate choice. Debouncing is for text inputs such as autocompletes, not for selects.

Step 4: The Component

<!-- src/components/LocationPicker.vue -->
<script setup>
import { useLocationSelect } from '../composables/useLocationSelect';

const emit = defineEmits(['submit']);

const {
  countryId, stateId, cityId,
  countryOptions, countriesStatus,
  stateOptions, statesStatus,
  cityOptions, citiesStatus,
  isLoading, errorMessage, statusMessage, isComplete, selection, retry,
} = useLocationSelect();

function onSubmit() {
  if (isComplete.value && !isLoading.value) {
    emit('submit', selection.value);
  }
}
</script>

<template>
  <form novalidate @submit.prevent="onSubmit">
    <div class="field">
      <label for="country">Country</label>
      <select
        id="country"
        v-model="countryId"
        :disabled="countriesStatus !== 'ready'"
        :aria-busy="countriesStatus === 'loading'"
        aria-describedby="location-status"
        required
      >
        <option value="">
          {{ countriesStatus === 'loading' ? 'Loading countries...' : 'Select a country' }}
        </option>
        <option v-for="c in countryOptions" :key="c.id" :value="c.id">{{ c.name }}</option>
      </select>
    </div>

    <div class="field">
      <label for="state">State / province</label>
      <select
        id="state"
        v-model="stateId"
        :disabled="statesStatus !== 'ready' || stateOptions.length === 0"
        :aria-busy="statesStatus === 'loading'"
        aria-describedby="location-status"
        required
      >
        <option value="">
          {{ statesStatus === 'loading' ? 'Loading states...' : 'Select a state' }}
        </option>
        <option v-for="s in stateOptions" :key="s.id" :value="s.id">{{ s.name }}</option>
      </select>
    </div>

    <div class="field">
      <label for="city">City</label>
      <select
        id="city"
        v-model="cityId"
        :disabled="citiesStatus !== 'ready' || cityOptions.length === 0"
        :aria-busy="citiesStatus === 'loading'"
        aria-describedby="location-status"
        required
      >
        <option value="">
          {{ citiesStatus === 'loading' ? 'Loading cities...' : 'Select a city' }}
        </option>
        <option v-for="c in cityOptions" :key="c.id" :value="c.id">{{ c.name }}</option>
      </select>
    </div>

    <p id="location-status" role="status" aria-live="polite">{{ statusMessage }}</p>

    <div role="alert">
      <template v-if="errorMessage">
        <p>{{ errorMessage }}</p>
        <button type="button" @click="retry">Try again</button>
      </template>
    </div>

    <button type="submit" :disabled="!isComplete || isLoading">Continue</button>
  </form>
</template>

A few accessibility details are worth noting:

  • Every select has a visible <label for>. A placeholder option is not a label.
  • The live region and the alert container are always in the DOM, and only their content changes. Screen readers are more reliable at announcing changes to an existing live region than at announcing one that has just been inserted.
  • The disabled selects tell keyboard users that a step is not available yet, and aria-busy marks the one that is waiting for data.
  • The country flag emoji is left out of the option text, because screen readers read it aloud before every country name.

Step 5: Use It

<!-- src/views/CheckoutView.vue -->
<script setup>
import LocationPicker from '../components/LocationPicker.vue';

function saveLocation({ country, state, city }) {
  // country.code is the ISO 3166-1 alpha-2 code, e.g. "ES"
  console.log(country.code, state?.name, city?.name);
}
</script>

<template>
  <h1>Shipping address</h1>
  <LocationPicker @submit="saveLocation" />
</template>

Save both the IDs and the display names. The IDs let you reload the lists when a user edits their address later, and the ISO code in country.code is what most payment and shipping providers expect (see ISO country codes explained).

Binding the Picker to a Parent Form

Emitting on submit works well for a standalone step. If the picker is one section of a larger form, you may prefer two-way binding instead. In Vue 3.4 and later, defineModel() can replace the internal refs: expose country, state and city models, and pass them into the composable instead of creating new refs there. The composable only needs refs, not refs that it created itself, so the change is small: accept optional refs as arguments and fall back to ref('') when none are given.

When you prefill an existing address, set the country first, wait for the state list to load, and then set the state. If you set all three IDs at once, the country watcher runs after them and clears the state and city again, which is exactly what it is designed to do.

Common Pitfalls

  • Destructuring reactive objects. If you wrap the composable's return value in reactive() and then destructure it, you lose reactivity. Keep the return value as plain refs, as shown above.
  • Passing the country name instead of its ID. The API accepts either, but names depend on lang and on exact spelling. Always pass the id from the previous response.
  • Relying on response.ok alone. An invalid key or an empty token balance still returns HTTP 200. The body.error check in the API module is what turns that into a message the user can see.
  • Using the array index as :key. When a list is replaced, index keys make Vue reuse option elements incorrectly. The id field is stable, so use it.

Persisting the Country List

The module-level cache only lasts until the page reloads. Country lists rarely change, so you can safely keep them in localStorage for a day or more. Wrap fetchOptions for that one resource:

// src/api/cachedCountries.js
import { fetchOptions } from './countryData';

const KEY = 'cda:countries:en';
const TTL_MS = 24 * 60 * 60 * 1000;

export async function fetchCountriesCached() {
  try {
    const saved = JSON.parse(localStorage.getItem(KEY) || 'null');
    if (saved && Date.now() - saved.savedAt < TTL_MS) return saved.data;
  } catch {
    // storage unavailable or corrupted: fall through to the network
  }
  const data = await fetchOptions('countries');
  try {
    localStorage.setItem(KEY, JSON.stringify({ data, savedAt: Date.now() }));
  } catch {
    // quota exceeded or private mode: ignore
  }
  return data;
}

To use it, make useOptionList accept a loader function instead of a resource name. If you render on the server with Nuxt, guard localStorage access with import.meta.client.

What It Costs

A user who fills in all three fields makes at most three requests, which is three tokens. Going back to a country already viewed costs nothing, thanks to the cache, and with the persisted country list, returning visitors usually skip the first request as well. Use the remaining tokens endpoint to monitor your balance, and add a lang argument when you localize the form so names come back in the user's language.

Related Guides

Get your API key and start with the select countries reference.

Related Guides