React city autocomplete tutorial

This builds a debounced city autocomplete input in React, backed by Geomelon's free prefix-search endpoint (oneshot.geomelon.dev) — no API key, so it's safe to call directly from the browser with no backend proxy in the way.

Live demo

This is the exact behavior the hook below implements, typing a French city:

Type to search France cities…

The hook

// useCityAutocomplete.ts
import { useState, useEffect, useRef } from 'react';

interface City {
  id: string;
  name: string;
  population: number | null;
  emoji: string | null;
  en?: string;
}

export function useCityAutocomplete(countryIso: string, lang: string, query: string) {
  const [results, setResults] = useState<City[]>([]);
  const [loading, setLoading] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => {
    const prefix = query.trim().toLowerCase();
    if (!prefix) {
      setResults([]);
      return;
    }

    setLoading(true);
    const timer = setTimeout(async () => {
      abortRef.current?.abort();
      const controller = new AbortController();
      abortRef.current = controller;

      try {
        const res = await fetch(
          `https://oneshot.geomelon.dev/${countryIso}/${lang}/${encodeURIComponent(prefix)}`,
          { signal: controller.signal },
        );
        setResults(res.ok ? await res.json() : []);
      } catch (err) {
        if ((err as Error).name !== 'AbortError') setResults([]);
      } finally {
        setLoading(false);
      }
    }, 150);

    return () => clearTimeout(timer);
  }, [countryIso, lang, query]);

  return { results, loading };
}

The input component

// CityAutocompleteInput.tsx
import { useState } from 'react';
import { useCityAutocomplete } from './useCityAutocomplete';

export function CityAutocompleteInput({ countryIso, lang }: { countryIso: string; lang: string }) {
  const [query, setQuery] = useState('');
  const { results, loading } = useCityAutocomplete(countryIso, lang, query);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Start typing a city..."
        autoComplete="off"
      />
      {loading && <span>Searching…</span>}
      <ul>
        {results.map((city) => (
          <li key={city.id} onClick={() => setQuery(city.name)}>
            {city.emoji} {city.name}
            {city.en && city.en !== city.name ? ` (${city.en})` : ''}
          </li>
        ))}
      </ul>
    </div>
  );
}

For search across countries, sorting, pagination, or multilingual name resolution beyond this free endpoint, swap the fetch call for GET /cities/search on the main API (requires a free RapidAPI key) — same debounce pattern, different URL.

Try the full API free on RapidAPI