Adressefelt med autocomplete i React

Byg en React-hook med debounce, AbortController og tastaturnavigation, og hold API-nøglen sikker som domænelåst browsernøgle.

5 min Niveau: Øvet ✓ Kode testet

Denne guide bygger et adressefelt i React uden ekstra biblioteker: en hook, der henter forslag fra /autocomplete, og en komponent med tastaturstyring og ARIA. Koden er TypeScript og virker i Vite, Create React App og Next.js-klientkomponenter.

Har du ikke brug for din egen komponent, kan du lægge det færdige script i en useEffect. Resten af guiden er til dig, der vil styre markup og styling selv.

Hvor nøglen skal ligge

Alt, hvad der kører i browseren, kan brugeren læse. Det gælder også miljøvariabler med VITE_, NEXT_PUBLIC_ eller REACT_APP_ foran: de bliver skrevet ind i JavaScript-bundtet ved build. Du har to muligheder:

  1. Browsernøgle. Opret en nøgle, og lås den til dine domæner under Nøgler. Kald fra andre domæner afvises med 403 origin_not_allowed, og hver besøgende har sin egen grænse på 120 kald i minuttet. Se Browsernøgler og tilladte domæner.
  2. Proxy. Lad din egen server kalde API'et med en almindelig nøgle. Så kommer nøglen aldrig ud i browseren. Se Next.js route handler som proxy.

Eksemplerne her bruger en browsernøgle. Vil du bruge en proxy, ændrer du kun URL'en og fjerner headeren.

Hooken

Hooken gør tre ting, som en naiv fetch i useEffect ikke gør:

  • Debounce. Den venter 200 ms efter sidste tastetryk. Ellers koster hvert bogstav et kald, og du rammer hurtigere grænsen pr. besøgende.
  • AbortController. Når brugeren skriver videre, afbrydes det gamle kald. Uden det kan et langsomt svar på "Strand" ankomme efter svaret på "Strandvejen 1" og overskrive det.
  • Fejlstatus. HTTP-status gemmes, så komponenten kan vise en besked og lade feltet virke som et almindeligt tekstfelt.
React (TSX)#use-adresse-forslag
import { useEffect, useState } from "react";

export type Forslag = {
  type: "vejnavn" | "adgangsadresse" | "adresse";
  tekst: string;
  forslagstekst: string;
  caretpos: number;
  data: {
    id?: string;
    adgangsadresseid?: string;
    vejnavn?: string;
    husnr?: string;
    etage?: string | null;
    "dør"?: string | null;
    postnr?: string;
    postnrnavn?: string;
    x?: number;
    y?: number;
  };
};

const API_URL = "https://api.danadresse.dk/autocomplete";
const API_KEY = "dawa_live_din_browsernøgle"; // domænelåst nøgle

export function useAdresseForslag(q: string, perSide = 8, debounceMs = 200) {
  const [forslag, setForslag] = useState<Forslag[]>([]);
  const [fejl, setFejl] = useState<number | null>(null); // HTTP-status, 0 = netværk

  useEffect(() => {
    if (q.trim().length < 2) {
      setForslag([]);
      return;
    }
    const ctrl = new AbortController();
    const timer = setTimeout(async () => {
      const url = new URL(API_URL);
      url.searchParams.set("q", q);
      url.searchParams.set("type", "adresse");
      url.searchParams.set("per_side", String(perSide));
      try {
        const res = await fetch(url, {
          headers: { "X-Api-Key": API_KEY },
          signal: ctrl.signal,
        });
        if (!res.ok) {
          setFejl(res.status);
          setForslag([]);
          return;
        }
        setForslag(await res.json());
        setFejl(null);
      } catch (e) {
        if ((e as Error).name !== "AbortError") {
          setFejl(0);
          setForslag([]);
        }
      }
    }, debounceMs);

    return () => {
      clearTimeout(timer);
      ctrl.abort();
    };
  }, [q, perSide, debounceMs]);

  return { forslag, fejl };
}

Oprydningsfunktionen i useEffect både stopper timeren og afbryder kaldet. Det håndterer også React StrictMode, som kører effekter to gange under udvikling.

Komponenten

/autocomplete svarer i tre faser: vejnavn, så adgangsadresse (husnummer), så adresse (etage og dør). Vælger brugeren et vejnavn, skal du ikke gemme det. Sæt teksten ind i feltet, flyt markøren til caretpos, og lad hooken søge igen. Først når type er adresse, har brugeren valgt en endelig adresse.

React (TSX)#adresse-felt
import { useId, useRef, useState, type KeyboardEvent } from "react";
import { useAdresseForslag, type Forslag } from "./useAdresseForslag";

export function AdresseFelt({ onVaelg }: { onVaelg: (f: Forslag) => void }) {
  const [q, setQ] = useState("");
  const [aaben, setAaben] = useState(false);
  const [aktiv, setAktiv] = useState(-1);
  const input = useRef<HTMLInputElement>(null);
  const listeId = useId();
  // Lukket liste = ingen kald, heller ikke efter et valg.
  const { forslag, fejl } = useAdresseForslag(aaben ? q : "");

  function vaelg(f: Forslag) {
    if (f.type !== "adresse") {
      // Drill-down: vej eller opgang → søg videre fra caretpos.
      setQ(f.tekst);
      setAktiv(-1);
      requestAnimationFrame(() => input.current?.setSelectionRange(f.caretpos, f.caretpos));
      return;
    }
    setQ(f.forslagstekst);
    setAaben(false);
    onVaelg(f);
  }

  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (!aaben || forslag.length === 0) return;
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setAktiv((i) => (i + 1) % forslag.length);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setAktiv((i) => (i <= 0 ? forslag.length - 1 : i - 1));
    } else if (e.key === "Enter" && aktiv >= 0) {
      e.preventDefault();
      vaelg(forslag[aktiv]);
    } else if (e.key === "Escape") {
      setAaben(false);
    }
  }

  const vis = aaben && forslag.length > 0;
  return (
    <div style={{ position: "relative" }}>
      <label htmlFor={`${listeId}-input`}>Adresse</label>
      <input
        id={`${listeId}-input`}
        ref={input}
        value={q}
        onChange={(e) => { setQ(e.target.value); setAaben(true); setAktiv(-1); }}
        onKeyDown={onKeyDown}
        onBlur={() => setAaben(false)}
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={vis}
        aria-controls={listeId}
        aria-activedescendant={vis && aktiv >= 0 ? `${listeId}-${aktiv}` : undefined}
        autoComplete="off"
      />
      {vis && (
        <ul id={listeId} role="listbox" aria-label="Adresseforslag">
          {forslag.map((f, i) => (
            <li
              key={f.data.id ?? f.tekst}
              id={`${listeId}-${i}`}
              role="option"
              aria-selected={i === aktiv}
              onMouseDown={(e) => { e.preventDefault(); vaelg(f); }}
            >
              {f.forslagstekst}
            </li>
          ))}
        </ul>
      )}
      <div role="status" aria-live="polite" className="sr-only">
        {fejl !== null
          ? "Adresseopslag er utilgængeligt. Skriv adressen manuelt."
          : vis ? `${forslag.length} forslag` : ""}
      </div>
    </div>
  );
}

onMouseDown med preventDefault() i stedet for onClick er bevidst: onClick kommer efter onBlur, og så er listen allerede lukket. Live-regionen med role="status" fortæller skærmlæsere, hvor mange forslag der er. Mønstret er forklaret i Tilgængelig adresse-combobox.

Brug komponenten og gem adressens id sammen med teksten:

React (TSX)#brug-felt
<AdresseFelt
  onVaelg={(f) => {
    setValues({
      adresse: f.forslagstekst,
      adresseId: f.data.id,
      postnr: f.data.postnr,
      by: f.data.postnrnavn,
    });
  }}
/>

data.id er adressens UUID. Gemmer du den, kan du senere slå adressen op med /adresser/{id} og få kommune, koordinater og andre felter uden at parse tekst.

Det kald, hooken laver

Du kan teste præcis samme forespørgsel fra Node 18 eller nyere. Det er nyttigt, når du skal finde ud af, om en fejl ligger i din komponent eller i nøglen:

JavaScript#hook-request
const url = new URL("https://api.danadresse.dk/autocomplete");
url.searchParams.set("q", "Strandvejen 100, 2900");
url.searchParams.set("type", "adresse");
url.searchParams.set("per_side", "8");

const res = await fetch(url, {
  headers: { "X-Api-Key": process.env.DANADRESSE_API_KEY },
});
console.log(res.status, res.headers.get("cache-control"));
for (const f of await res.json()) console.log(f.type, f.forslagstekst);

API'et sender Cache-Control: private, max-age=300 på /autocomplete. Browseren kan derfor besvare en gentaget forespørgsel fra sin egen cache, fx når brugeren sletter et par bogstaver og skriver dem igen.

Afgræns forslagene

Sæt disse parametre i hooken med url.searchParams.set(…), når din formular har brug for dem:

ParameterVirkning
postnr=2900Kun adresser i det postnummer
kommunekode=0101Kun adresser i den kommune. 101 virker også
fuzzy=Tolerance for stavefejl slået til (et DAWA-flag). Udeladt eller fuzzy=false: slået fra
srid=25832data.x og data.y i ETRS89/UTM32 i stedet for WGS84 (4326, standard). Andre værdier giver 400
adgangsadresseid=…Viser enhederne (etage og dør) i én opgang

Adresseforslag har etage, dør og adgangsadresseid i data. Samme kald fra terminalen:

Shell#narrow-request
curl -s "https://api.danadresse.dk/autocomplete?q=Strandvejen%20100&type=adresse&postnr=2900&fuzzy=false&per_side=3" \
  -H "X-Api-Key: $DANADRESSE_API_KEY"

Fejl, du kan møde

StatusKodeÅrsag
401missing_api_keyNøglen kom ikke med. Tjek headeren X-Api-Key
403origin_not_allowedSiden kører på et domæne, der ikke er på nøglens liste. Husk localhost til udvikling
429rate_limit_per_visitorÉn besøgende har lavet for mange kald i minuttet. Øg debounce
429quota_exceededGratisplanens månedlige kvote og dens buffer på 20 % er brugt. Det varer til den 1. i næste måned

Fejlsvar har CORS-headere, også ved 403 og 500, så browseren kan læse både res.status og JSON-svaret. Byg din logik på code i svaret. API'et gør også Retry-After, X-RateLimit-*, X-RateLimit-Minute-* og X-Quota-* læsbare for JavaScript. Efter en 429 rate_limit_per_visitor venter du det antal sekunder, der står i res.headers.get("Retry-After"), før næste kald. Prøv aldrig quota_exceeded igen automatisk.

Næste skridt

Relaterede guides

Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)

Prøv det med en gratis nøgle

2 000 kald/måned på gratisplanen. Intet kreditkort.

Opret konto →