# Address autocomplete field in React

> Build a React hook with debounce, AbortController and keyboard navigation, and keep the API key safe as a domain-locked browser key.
>
> https://danadresse.dk/en/guides/react-autocomplete · verified 2026-11-02
This guide builds an address field in React with no extra libraries: a hook that fetches suggestions from `/autocomplete`, and a component with keyboard control and ARIA. The code is TypeScript and works in Vite, Create React App and Next.js client components.

If you do not need your own component, you can load [the ready-made script](/en/guides/vanilla-autocomplete-widget) in a `useEffect`. The rest of this guide is for when you want to own the markup and styling.

## Where the key lives

Anything that runs in the browser can be read by the user. That includes environment variables prefixed `VITE_`, `NEXT_PUBLIC_` or `REACT_APP_`: they are written into the JavaScript bundle at build time. You have two options:

1. **Browser key.** Create a key and lock it to your domains under [Keys](/dashboard/keys). Calls from other domains get 403 `origin_not_allowed`, and each visitor has their own cap of 120 calls per minute. See [Browser keys and allowed domains](/en/guides/browser-noegler-domaener).
2. **Proxy.** Let your own server call the API with a regular key. The key never reaches the browser. See [Next.js route handler as a proxy](/en/guides/nextjs-proxy).

The examples here use a browser key. To use a proxy, change only the URL and drop the header.

## The hook

The hook does three things a naive `fetch` in `useEffect` does not:

- **Debounce.** It waits 200 ms after the last keystroke. Otherwise every letter costs a call, and you reach the per-visitor cap sooner.
- **AbortController.** When the user keeps typing, the old request is cancelled. Without it, a slow answer for "Strand" can arrive after the answer for "Strandvejen 1" and overwrite it.
- **Error status.** The HTTP status is kept, so the component can show a message and fall back to a plain text field.

```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_your_browser_key"; // domain-locked key

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

  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 };
}
```

The cleanup function in `useEffect` both stops the timer and cancels the request. That also covers React StrictMode, which runs effects twice in development.

## The component

`/autocomplete` answers in three phases: street name, then access address (house number), then address (floor and door). When the user picks a street name, do not store it. Put the text in the field, move the caret to `caretpos` and let the hook search again. Only when `type` is `adresse` has the user chosen a final address.

```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();
  // Closed list = no calls, not even after a choice.
  const { forslag, fejl } = useAdresseForslag(aaben ? q : "");

  function vaelg(f: Forslag) {
    if (f.type !== "adresse") {
      // Drill-down: street or entrance → search on from 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`}>Address</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="Address suggestions">
          {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
          ? "Address lookup is unavailable. Type the address manually."
          : vis ? `${forslag.length} suggestions` : ""}
      </div>
    </div>
  );
}
```

`onMouseDown` with `preventDefault()` instead of `onClick` is deliberate: `onClick` fires after `onBlur`, by which time the list is already closed. The live region with `role="status"` tells screen readers how many suggestions there are. The pattern is explained in [Accessible address combobox](/en/guides/tilgaengelig-combobox).

Use the component and store the address id alongside the text:

```tsx id=brug-felt run=no
<AdresseFelt
  onVaelg={(f) => {
    setValues({
      address: f.forslagstekst,
      addressId: f.data.id,
      postcode: f.data.postnr,
      city: f.data.postnrnavn,
    });
  }}
/>
```

`data.id` is the address UUID. If you store it, you can later look the address up with `/adresser/{id}` and get the municipality, coordinates and other fields without parsing text.

## The request the hook makes

You can test exactly the same request from Node 18 or later. That helps when you need to know whether a fault is in your component or in the key:

```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);
```

The API sends `Cache-Control: private, max-age=300` on `/autocomplete`. The browser can therefore answer a repeated query from its own cache, for example when the user deletes a few letters and types them again.

## Narrow the suggestions

Add these parameters in the hook with `url.searchParams.set(…)` when your form needs them:

| Parameter | Effect |
|---|---|
| `postnr=2900` | Only addresses in that postcode |
| `kommunekode=0101` | Only addresses in that municipality. `101` works too |
| `fuzzy=` | Typo tolerance on (a DAWA flag). Left out or `fuzzy=false`: off |
| `srid=25832` | `data.x` and `data.y` in ETRS89/UTM32 instead of WGS84 (`4326`, the default). Other values give 400 |
| `adgangsadresseid=…` | Lists the units (floor and door) of one entrance |

Address suggestions carry `etage`, `dør` and `adgangsadresseid` in `data`. The same request from a terminal:

```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"
```

## Errors you may see

| Status | Code | Cause |
|---|---|---|
| 401 | `missing_api_key` | The key was not sent. Check the `X-Api-Key` header |
| 403 | `origin_not_allowed` | The page runs on a domain that is not on the key's list. Remember `localhost` for development |
| 429 | `rate_limit_per_visitor` | One visitor made too many calls in a minute. Increase the debounce |
| 429 | `quota_exceeded` | The free plan's monthly quota and its 20 % grace are used up. It lasts until the 1st of next month |

Error responses carry CORS headers, also on 403 and 500, so the browser can read both `res.status` and the JSON body. Branch on `code` in the body. The API also exposes `Retry-After`, `X-RateLimit-*`, `X-RateLimit-Minute-*` and `X-Quota-*` to JavaScript. After a 429 `rate_limit_per_visitor`, wait the number of seconds in `res.headers.get("Retry-After")` before the next call. Never retry `quota_exceeded` automatically.

## Next steps

- [Browser keys and allowed domains](/en/guides/browser-noegler-domaener)
- [Next.js route handler as a proxy](/en/guides/nextjs-proxy) — no key in the browser.
- [Accessible address combobox](/en/guides/tilgaengelig-combobox)
- Overview of React and the API: [/en/react-adresse-autocomplete](/en/react-adresse-autocomplete).
