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.
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:
- 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. - 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.
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.
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:
<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:
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:
| Parameter | Virkning |
|---|---|
postnr=2900 | Kun adresser i det postnummer |
kommunekode=0101 | Kun 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=25832 | data.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:
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
| Status | Kode | Årsag |
|---|---|---|
| 401 | missing_api_key | Nøglen kom ikke med. Tjek headeren X-Api-Key |
| 403 | origin_not_allowed | Siden kører på et domæne, der ikke er på nøglens liste. Husk localhost til udvikling |
| 429 | rate_limit_per_visitor | Én besøgende har lavet for mange kald i minuttet. Øg debounce |
| 429 | quota_exceeded | Gratisplanens 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
- Browsernøgler og tilladte domæner
- Next.js route handler som proxy — uden nøgle i browseren.
- Tilgængelig adresse-combobox
- Oversigt over React og API'et: /react-adresse-autocomplete.
Relaterede guides
Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)