# 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.
>
> https://danadresse.dk/guides/react-autocomplete · verified 2026-11-02
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](/guides/vanilla-autocomplete-widget) 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](/dashboard/keys). 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](/guides/browser-noegler-domaener).
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](/guides/nextjs-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.

```tsx id=use-adresse-forslag run=no
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.

```tsx id=adresse-felt run=no
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](/guides/tilgaengelig-combobox).

Brug komponenten og gem adressens id sammen med teksten:

```tsx id=brug-felt run=no
<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:

```js id=hook-request run=live expect="Strandvejen"
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:

```bash id=narrow-request run=live expect="2900"
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](/guides/browser-noegler-domaener)
- [Next.js route handler som proxy](/guides/nextjs-proxy) — uden nøgle i browseren.
- [Tilgængelig adresse-combobox](/guides/tilgaengelig-combobox)
- Oversigt over React og API'et: [/react-adresse-autocomplete](/react-adresse-autocomplete).
