API Documentation - Endpoints & Examples

Vue 3 Integration

Build Vue 3 address forms with composables for CountryDataAPI

Vue 3 composables to easily integrate CountryDataAPI with your Vue application. Reactive, typed, and ready for production.

Overview

This guide provides production-ready Vue 3 composables for:

  • useCountries - Load all countries with caching
  • useStates - Load states by country ID
  • useCities - Load cities by state ID
  • useAddress - Combined composable for complete forms

All composables are reactive, include error handling, and fully support TypeScript.

Installation

No additional packages required! CountryDataAPI works with the native fetch API.

# Just install Vue 3 (if you haven't already)
npm install vue

TypeScript Types

First, define the TypeScript interfaces:

// types/address.ts

export interface Country {
  id: string;
  name: string;
}

export interface State {
  id: string;
  name: string;
}

export interface City {
  id: string;
  name: string;
}

export interface ApiResponse<T> {
  success: boolean;
  data: T[];
  error?: {
    code: string;
    message: string;
  };
}

Configuration

Create a configuration file for your API settings:

// config/api.ts

export const API_CONFIG = {
  baseUrl: 'https://api.countrydataapi.com/v1',
  apiKey: import.meta.env.VITE_COUNTRY_API_KEY || '',
};

export function buildApiUrl(
  endpoint: string,
  params: Record<string, string>
): string {
  const url = new URL(`${API_CONFIG.baseUrl}${endpoint}`);
  url.searchParams.set('apikey', API_CONFIG.apiKey);

  Object.entries(params).forEach(([key, value]) => {
    if (value) url.searchParams.set(key, value);
  });

  return url.toString();
}

Important: Store your API key in .env file as VITE_COUNTRY_API_KEY and never commit it to version control!

Create a .env file:

VITE_COUNTRY_API_KEY=your-api-key-here

Composables

useCountries Composable

Load all countries with optional caching:

// composables/useCountries.ts

import { ref, readonly, onMounted } from 'vue';
import type { Country, ApiResponse } from '../types/address';
import { buildApiUrl } from '../config/api';

const CACHE_KEY = 'countrydataapi_countries';
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days

interface CachedData {
  data: Country[];
  timestamp: number;
}

export function useCountries(lang: string = 'en', enableCache: boolean = true) {
  const countries = ref<Country[]>([]);
  const loading = ref(true);
  const error = ref<string | null>(null);

  const fetchCountries = async () => {
    loading.value = true;
    error.value = null;

    // Check cache first
    if (enableCache) {
      const cached = localStorage.getItem(CACHE_KEY);
      if (cached) {
        const { data: cachedData, timestamp }: CachedData = JSON.parse(cached);
        if (Date.now() - timestamp < CACHE_DURATION) {
          countries.value = cachedData;
          loading.value = false;
          return;
        }
      }
    }

    try {
      const url = buildApiUrl('/select/countries', { lang });
      const response = await fetch(url);
      const result: ApiResponse<Country> = await response.json();

      if (!result.success) {
        throw new Error(result.error?.message || 'Failed to load countries');
      }

      countries.value = result.data;

      // Cache the result
      if (enableCache) {
        const cacheData: CachedData = {
          data: result.data,
          timestamp: Date.now(),
        };
        localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
      }
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Network error';
    } finally {
      loading.value = false;
    }
  };

  onMounted(() => {
    fetchCountries();
  });

  return {
    countries: readonly(countries),
    loading: readonly(loading),
    error: readonly(error),
    refetch: fetchCountries,
  };
}

useStates Composable

Load states filtered by country:

// composables/useStates.ts

import { ref, readonly, watch } from 'vue';
import type { Ref } from 'vue';
import type { State, ApiResponse } from '../types/address';
import { buildApiUrl } from '../config/api';

export function useStates(countryId: Ref<string | null>, lang: string = 'en') {
  const states = ref<State[]>([]);
  const loading = ref(false);
  const error = ref<string | null>(null);

  const fetchStates = async () => {
    if (!countryId.value) {
      states.value = [];
      return;
    }

    loading.value = true;
    error.value = null;

    try {
      const url = buildApiUrl('/select/states', {
        country: countryId.value,
        lang,
      });
      const response = await fetch(url);
      const result: ApiResponse<State> = await response.json();

      if (!result.success) {
        throw new Error(result.error?.message || 'Failed to load states');
      }

      states.value = result.data;
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Network error';
      states.value = [];
    } finally {
      loading.value = false;
    }
  };

  watch(countryId, fetchStates, { immediate: true });

  return {
    states: readonly(states),
    loading: readonly(loading),
    error: readonly(error),
    refetch: fetchStates,
  };
}

useCities Composable

Load cities filtered by state:

// composables/useCities.ts

import { ref, readonly, watch } from 'vue';
import type { Ref } from 'vue';
import type { City, ApiResponse } from '../types/address';
import { buildApiUrl } from '../config/api';

export function useCities(stateId: Ref<string | null>, lang: string = 'en') {
  const cities = ref<City[]>([]);
  const loading = ref(false);
  const error = ref<string | null>(null);

  const fetchCities = async () => {
    if (!stateId.value) {
      cities.value = [];
      return;
    }

    loading.value = true;
    error.value = null;

    try {
      const url = buildApiUrl('/select/cities', {
        state: stateId.value,
        lang,
      });
      const response = await fetch(url);
      const result: ApiResponse<City> = await response.json();

      if (!result.success) {
        throw new Error(result.error?.message || 'Failed to load cities');
      }

      cities.value = result.data;
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Network error';
      cities.value = [];
    } finally {
      loading.value = false;
    }
  };

  watch(stateId, fetchCities, { immediate: true });

  return {
    cities: readonly(cities),
    loading: readonly(loading),
    error: readonly(error),
    refetch: fetchCities,
  };
}

AddressForm Component

Complete example component using all composables:

<!-- components/AddressForm.vue -->

<script setup lang="ts">
import { ref, computed } from 'vue';
import { useCountries } from '../composables/useCountries';
import { useStates } from '../composables/useStates';
import { useCities } from '../composables/useCities';

interface AddressFormData {
  country: string;
  state: string;
  city: string;
  street: string;
  zipCode: string;
}

interface Props {
  initialData?: Partial<AddressFormData>;
  lang?: string;
}

const props = withDefaults(defineProps<Props>(), {
  lang: 'en',
});

const emit = defineEmits<{
  submit: [data: AddressFormData];
}>();

// Form data
const selectedCountry = ref<string>('');
const selectedState = ref<string>('');
const selectedCity = ref<string>('');
const street = ref<string>('');
const zipCode = ref<string>('');

// Initialize from props
if (props.initialData) {
  selectedCountry.value = props.initialData.country || '';
  selectedState.value = props.initialData.state || '';
  selectedCity.value = props.initialData.city || '';
  street.value = props.initialData.street || '';
  zipCode.value = props.initialData.zipCode || '';
}

// Use composables
const {
  countries,
  loading: loadingCountries,
  error: countriesError,
} = useCountries(props.lang);

const {
  states,
  loading: loadingStates,
  error: statesError,
} = useStates(selectedCountry, props.lang);

const {
  cities,
  loading: loadingCities,
  error: citiesError,
} = useCities(selectedState, props.lang);

// Handlers
const handleCountryChange = () => {
  selectedState.value = '';
  selectedCity.value = '';
};

const handleStateChange = () => {
  selectedCity.value = '';
};

const handleSubmit = () => {
  if (!isFormValid.value) return;

  emit('submit', {
    country: selectedCountry.value,
    state: selectedState.value,
    city: selectedCity.value,
    street: street.value,
    zipCode: zipCode.value,
  });
};

// Computed
const isFormValid = computed(() => {
  return (
    selectedCountry.value &&
    selectedState.value &&
    selectedCity.value &&
    street.value
  );
});
</script>

<template>
  <form @submit.prevent="handleSubmit" class="address-form">
    <!-- Country -->
    <div class="form-group">
      <label for="country">
        Country *
        <span v-if="loadingCountries" class="loading">Loading...</span>
      </label>
      <select
        id="country"
        v-model="selectedCountry"
        @change="handleCountryChange"
        :disabled="loadingCountries"
        required
      >
        <option value="">Select country...</option>
        <option
          v-for="country in countries"
          :key="country.id"
          :value="country.id"
        >
          {{ country.name }}
        </option>
      </select>
      <span v-if="countriesError" class="error">{{ countriesError }}</span>
    </div>

    <!-- State -->
    <div class="form-group">
      <label for="state">
        State/Province *
        <span v-if="loadingStates" class="loading">Loading...</span>
      </label>
      <select
        id="state"
        v-model="selectedState"
        @change="handleStateChange"
        :disabled="!selectedCountry || loadingStates"
        required
      >
        <option value="">Select state...</option>
        <option v-for="state in states" :key="state.id" :value="state.id">
          {{ state.name }}
        </option>
      </select>
      <span v-if="statesError" class="error">{{ statesError }}</span>
    </div>

    <!-- City -->
    <div class="form-group">
      <label for="city">
        City *
        <span v-if="loadingCities" class="loading">Loading...</span>
      </label>
      <select
        id="city"
        v-model="selectedCity"
        :disabled="!selectedState || loadingCities"
        required
      >
        <option value="">Select city...</option>
        <option v-for="city in cities" :key="city.id" :value="city.id">
          {{ city.name }}
        </option>
      </select>
      <span v-if="citiesError" class="error">{{ citiesError }}</span>
    </div>

    <!-- Street -->
    <div class="form-group">
      <label for="street">Street Address *</label>
      <input
        type="text"
        id="street"
        v-model="street"
        placeholder="123 Main Street"
        required
      />
    </div>

    <!-- ZIP Code -->
    <div class="form-group">
      <label for="zipCode">ZIP Code</label>
      <input
        type="text"
        id="zipCode"
        v-model="zipCode"
        placeholder="12345"
      />
    </div>

    <!-- Submit -->
    <button type="submit" :disabled="!isFormValid">
      Submit Address
    </button>
  </form>
</template>

<style scoped>
.address-form {
  max-width: 600px;
  margin: 0 auto;
}

.form-group {
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: 600;
  color: #1f2937;
  font-size: 14px;
}

.form-group select,
.form-group input {
  width: 100%;
  padding: 10px 12px;
  border: 2px solid #e5e7eb;
  border-radius: 6px;
  font-size: 16px;
  background-color: white;
  transition: border-color 0.2s;
}

.form-group select:focus,
.form-group input:focus {
  outline: none;
  border-color: #3b82f6;
}

.form-group select:disabled,
.form-group input:disabled {
  background-color: #f9fafb;
  cursor: not-allowed;
  opacity: 0.6;
}

.loading {
  color: #6b7280;
  font-size: 12px;
  font-weight: normal;
  font-style: italic;
  margin-left: 8px;
}

.error {
  display: block;
  margin-top: 6px;
  color: #ef4444;
  font-size: 13px;
}

button[type='submit'] {
  width: 100%;
  padding: 12px 24px;
  background-color: #3b82f6;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.2s;
}

button[type='submit']:hover:not(:disabled) {
  background-color: #2563eb;
}

button[type='submit']:disabled {
  background-color: #cbd5e0;
  cursor: not-allowed;
}
</style>

Usage Example

<!-- App.vue -->

<script setup lang="ts">
import AddressForm from './components/AddressForm.vue';

const handleAddressSubmit = (data: any) => {
  console.log('Address submitted:', data);

  // Send to your backend
  // fetch('/api/save-address', {
  //   method: 'POST',
  //   headers: { 'Content-Type': 'application/json' },
  //   body: JSON.stringify(data),
  // });
};
</script>

<template>
  <div class="app">
    <h1>Delivery Address</h1>
    <AddressForm @submit="handleAddressSubmit" lang="en" />
  </div>
</template>

<style>
.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 40px 20px;
}

h1 {
  text-align: center;
  margin-bottom: 40px;
  color: #111827;
}
</style>

Advanced: useAddress Combined Composable

For more complex scenarios, create a combined composable:

// composables/useAddress.ts

import { ref, readonly } from 'vue';
import { useCountries } from './useCountries';
import { useStates } from './useStates';
import { useCities } from './useCities';

export function useAddress(lang: string = 'en') {
  const selectedCountry = ref<string | null>(null);
  const selectedState = ref<string | null>(null);
  const selectedCity = ref<string | null>(null);

  const countries = useCountries(lang);
  const states = useStates(selectedCountry, lang);
  const cities = useCities(selectedState, lang);

  const selectCountry = (countryId: string) => {
    selectedCountry.value = countryId;
    selectedState.value = null;
    selectedCity.value = null;
  };

  const selectState = (stateId: string) => {
    selectedState.value = stateId;
    selectedCity.value = null;
  };

  const selectCity = (cityId: string) => {
    selectedCity.value = cityId;
  };

  const reset = () => {
    selectedCountry.value = null;
    selectedState.value = null;
    selectedCity.value = null;
  };

  return {
    // Data
    countries,
    states,
    cities,

    // Selected values
    selectedCountry: readonly(selectedCountry),
    selectedState: readonly(selectedState),
    selectedCity: readonly(selectedCity),

    // Actions
    selectCountry,
    selectState,
    selectCity,
    reset,
  };
}

Using the Combined Composable

<script setup lang="ts">
import { useAddress } from '../composables/useAddress';

const {
  countries,
  states,
  cities,
  selectedCountry,
  selectedState,
  selectedCity,
  selectCountry,
  selectState,
  selectCity,
  reset,
} = useAddress('en');

const handleSubmit = () => {
  console.log({
    country: selectedCountry.value,
    state: selectedState.value,
    city: selectedCity.value,
  });
};
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <select @change="(e) => selectCountry(e.target.value)">
      <option value="">Select country...</option>
      <option
        v-for="country in countries.countries"
        :key="country.id"
        :value="country.id"
      >
        {{ country.name }}
      </option>
    </select>

    <!-- States and Cities... -->

    <button type="button" @click="reset">Reset</button>
    <button type="submit">Submit</button>
  </form>
</template>

Composition API with Options API

If you're using Options API, you can still use composables:

<script lang="ts">
import { defineComponent } from 'vue';
import { useCountries } from '../composables/useCountries';

export default defineComponent({
  setup() {
    const { countries, loading, error } = useCountries('en');

    return {
      countries,
      loading,
      error,
    };
  },
});
</script>

Testing Composables

Example test with Vitest:

// composables/__tests__/useCountries.spec.ts

import { describe, it, expect, vi } from 'vitest';
import { useCountries } from '../useCountries';

describe('useCountries', () => {
  it('should fetch countries on mount', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({
        json: () =>
          Promise.resolve({
            success: true,
            data: [
              { id: '1', name: 'United States' },
              { id: '2', name: 'Canada' },
            ],
          }),
      })
    ) as any;

    const { countries, loading } = useCountries();

    expect(loading.value).toBe(true);

    // Wait for fetch to complete
    await new Promise((resolve) => setTimeout(resolve, 100));

    expect(countries.value).toHaveLength(2);
    expect(loading.value).toBe(false);
  });
});

Next Steps

Need Help?


Pro Tip: Use provide/inject to share the API configuration across your app, making it easier to switch between development and production environments.