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.
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 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:
- Browser key. Create a key and lock it to your domains under 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. - 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.
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.
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.
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.
Use the component and store the address id alongside the text:
<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:
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:
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
- Next.js route handler as a proxy — no key in the browser.
- Accessible address combobox
- Overview of React and the API: /en/react-adresse-autocomplete.
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)