Autocomplétion d'Adresses

D'un champ vide à une adresse validée et enregistrée

Ce guide construit l'étape adresse d'un tunnel de commande : l'utilisateur tape, choisit une ville, et les champs pays, région et code postal se remplissent tout seuls et se valident. Environ 60 lignes de JavaScript.

Trois endpoints font le travail :

  1. /v1/places/autocomplete — des suggestions pendant la saisie
  2. /v1/places/details — la fiche complète de ce qui a été choisi
  3. /v1/places/validate — vérification finale avant d'enregistrer

D'abord, le jeton de session

Une autocomplétion envoie une requête par frappe. Pour garder un coût prévisible, générez un sessiontoken quand le champ prend le focus et réutilisez-le pour toutes les requêtes de ce champ, y compris l'appel final à details. Le champ entier coûte 1 jeton, quelle que soit la quantité de texte saisie.

let session = crypto.randomUUID();

input.addEventListener('focus', () => {
  session = crypto.randomUUID(); // une session par adresse saisie par l'utilisateur
});

Les sessions durent 3 minutes. Si vous omettez le jeton, chaque requête coûte 1 jeton.

JavaScript pur

<input id="city" placeholder="Commencez à saisir votre ville..." autocomplete="off" />
<ul id="suggestions"></ul>

<input id="country" readonly />
<input id="state" readonly />
<input id="zip" placeholder="Code postal" />
<p id="zip-error"></p>
const API_KEY = 'votre-cle-api';
const BASE = 'https://api.countrydataapi.com/v1/places';

const input = document.getElementById('city');
const list = document.getElementById('suggestions');

let session = crypto.randomUUID();
let timer;
let postal = null;

input.addEventListener('focus', () => { session = crypto.randomUUID(); });

// Debounce : une requête par pause, pas par frappe.
input.addEventListener('input', () => {
  clearTimeout(timer);
  const q = input.value.trim();
  if (q.length < 2) { list.innerHTML = ''; return; }

  timer = setTimeout(() => search(q), 150);
});

async function search(q) {
  const params = new URLSearchParams({
    apikey: API_KEY,
    q,
    types: 'city',
    limit: '5',
    lang: 'fr',
    sessiontoken: session,
  });

  const response = await fetch(`${BASE}/autocomplete?${params}`);
  const { suggestions } = await response.json();

  list.innerHTML = '';
  suggestions.forEach((suggestion) => {
    const item = document.createElement('li');
    item.textContent = suggestion.description; // "Madrid, Communauté de Madrid, Espagne"
    item.onclick = () => choose(suggestion);
    list.appendChild(item);
  });
}

async function choose(suggestion) {
  list.innerHTML = '';
  input.value = suggestion.name;

  const params = new URLSearchParams({
    apikey: API_KEY,
    id: suggestion.id,
    type: suggestion.type,
    lang: 'fr',
    sessiontoken: session, // clôt la session : cet appel est gratuit
  });

  const response = await fetch(`${BASE}/details?${params}`);
  const { place } = await response.json();

  document.getElementById('country').value = place.components.country?.name ?? '';
  document.getElementById('state').value = place.components.state?.name ?? '';

  // Les règles de code postal du pays, pour valider le champ suivant dans le navigateur.
  postal = place.postal;
  document.getElementById('zip').placeholder = postal.example ?? 'Code postal';
}

document.getElementById('zip').addEventListener('blur', (event) => {
  const error = document.getElementById('zip-error');
  if (!postal?.regex) { error.textContent = ''; return; }

  const ok = new RegExp(postal.regex).test(event.target.value.trim());
  error.textContent = ok ? '' : `Format attendu : ${postal.example ?? postal.format}`;
});

Voilà toute l'interaction. Le champ description est déjà formaté pour une liste déroulante, il n'y a donc aucune chaîne à assembler.

React

import { useEffect, useRef, useState } from 'react';

const API_KEY = 'votre-cle-api';
const BASE = 'https://api.countrydataapi.com/v1/places';

export function useAddressAutocomplete() {
  const [query, setQuery] = useState('');
  const [suggestions, setSuggestions] = useState([]);
  const [address, setAddress] = useState(null);
  const session = useRef(crypto.randomUUID());

  useEffect(() => {
    if (query.trim().length < 2) {
      setSuggestions([]);
      return;
    }

    // Annule les requêtes en cours pour qu'une réponse lente n'écrase pas une plus récente.
    const controller = new AbortController();
    const timer = setTimeout(async () => {
      const params = new URLSearchParams({
        apikey: API_KEY,
        q: query.trim(),
        types: 'city',
        limit: '5',
        lang: 'fr',
        sessiontoken: session.current,
      });

      try {
        const response = await fetch(`${BASE}/autocomplete?${params}`, {
          signal: controller.signal,
        });
        const data = await response.json();
        setSuggestions(data.suggestions ?? []);
      } catch (error) {
        if (error.name !== 'AbortError') throw error;
      }
    }, 150);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);

  async function select(suggestion) {
    const params = new URLSearchParams({
      apikey: API_KEY,
      id: suggestion.id,
      type: suggestion.type,
      lang: 'fr',
      sessiontoken: session.current,
    });

    const response = await fetch(`${BASE}/details?${params}`);
    const { place } = await response.json();

    setAddress(place);
    setSuggestions([]);
    setQuery(place.name);
    session.current = crypto.randomUUID(); // adresse suivante, session suivante
  }

  return { query, setQuery, suggestions, address, select };
}
function AddressField() {
  const { query, setQuery, suggestions, address, select } = useAddressAutocomplete();

  return (
    <div>
      <input
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        placeholder="Commencez à saisir votre ville..."
        autoComplete="off"
      />

      {suggestions.length > 0 && (
        <ul>
          {suggestions.map((suggestion) => (
            <li key={suggestion.id} onClick={() => select(suggestion)}>
              {suggestion.description}
            </li>
          ))}
        </ul>
      )}

      {address && (
        <>
          <input readOnly value={address.components.country?.name ?? ''} />
          <input readOnly value={address.components.state?.name ?? ''} />
          <input placeholder={address.postal.example ?? 'Code postal'} />
        </>
      )}
    </div>
  );
}

Validation finale côté serveur

La validation dans le navigateur est un confort pour l'utilisateur. Avant d'enregistrer ou d'expédier quoi que ce soit, confirmez la combinaison complète côté serveur :

const params = new URLSearchParams({
  apikey: process.env.COUNTRY_DATA_API_KEY,
  country: form.country,
  state: form.state,
  city: form.city,
  zipcode: form.zip,
  lang: 'fr',
});

const response = await fetch(
  `https://api.countrydataapi.com/v1/places/validate?${params}`
);
const { result } = await response.json();

if (!result.valid && result.has_corrections) {
  // Proposez la correction au lieu de rejeter le formulaire d'emblée.
  return { needsConfirmation: result.corrections };
}

// Enregistrez la forme canonique, pas ce que l'utilisateur a tapé.
await orders.save({
  country: result.normalized.country?.name,
  state: result.normalized.state?.name,
  city: result.normalized.city?.name,
  zipcode: result.normalized.zipcode,
});

Enregistrer result.normalized plutôt que la saisie brute, c'est ce qui rend vos données d'adresses interrogeables par la suite : pas de Cataluna à côté d'un Cataluña, pas de MADRID à côté d'un Madrid.

En demander moins

Si votre formulaire ne comporte qu'un code postal et une ville, vous n'avez pas besoin du champ province : le code postal le résout.

const { result } = await api.places.validate({ country: 'ES', zipcode: '28001' });
result.normalized.state?.name; // "Communauté de Madrid"

Un champ de moins améliore de façon mesurable la conversion d'un tunnel de commande.

Ce que cela coûte

Action Jetons
Un champ d'adresse complet (autant de frappes que nécessaire + details) 1
Validation côté serveur du formulaire soumis 1
Formats postaux de tous les pays (à mettre en cache) 1, une fois

Une adresse de commande complétée coûte donc 2 jetons, et peu importe la vitesse de frappe de l'utilisateur.

Ce que cela ne fait pas

Le jeu de données est administratif : pays, états, villes et codes postaux. Il n'y a pas de données au niveau de la rue, pas de coordonnées en dessous du niveau pays, et pas de points d'intérêt — ni commerces, ni horaires, ni avis, ni photos. Si vous devez géocoder une adresse postale complète ou rechercher un restaurant, ce n'est pas le bon outil.

Voir aussi