Vue 3 composables to easily integrate CountryDataAPI with your Vue application. Reactive, typed, and ready for production.
This guide provides production-ready Vue 3 composables for:
All composables are reactive, include error handling, and fully support TypeScript.
No additional packages required! CountryDataAPI works with the native fetch API.
# Just install Vue 3 (if you haven't already)
npm install vue
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;
};
}
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
.envfile asVITE_COUNTRY_API_KEYand never commit it to version control!
Create a .env file:
VITE_COUNTRY_API_KEY=your-api-key-here
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,
};
}
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,
};
}
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,
};
}
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>
<!-- 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>
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,
};
}
<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>
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>
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);
});
});
Pro Tip: Use
provide/injectto share the API configuration across your app, making it easier to switch between development and production environments.