Address autocomplete in React

About 15 minutes. Updated 11 September 2026.

A small hook and a component that give a React form Australian address autocomplete from the Locio API, with debouncing, request cancellation and the picked address handed to the parent as a typed object.

Two files: a hook that turns a query into results, and a component that renders them. The hook owns the debounce and the cancellation, which is where autocomplete usually goes wrong, so the component stays a plain form control.

01The hook

useEffect with cleanup is exactly the shape this needs. Each change to the query starts a timer; the cleanup for the previous run clears its timer and aborts its request, so only the latest answer can reach state.

src/useAddressSearch.tstypescript
// useAddressSearch.ts
import { useEffect, useState } from "react";

export type Address = {
  // The id in the register the address came from. In Australia it is the
  // G-NAF Address Detail PID, sent as address_detail_pid as well.
  id: string;
  formatted: string;
  lat: number;
  lng: number;
  mesh_block?: string;          // Australian records only
  components: Record<string, string>;
};

const KEY = import.meta.env.VITE_LOCIO_KEY as string;   // a public key
const API = "https://api.locio.com.au";

export function useAddressSearch(query: string) {
  const [results, setResults] = useState<Address[]>([]);
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    const q = query.trim();
    if (q.length < 3) {
      setResults([]);
      return;
    }

    const controller = new AbortController();
    // 200ms of quiet, then one request. Cleanup cancels both the timer and
    // the fetch, so a stale answer can never overwrite a newer one.
    const timer = setTimeout(async () => {
      setBusy(true);
      try {
        const url = new URL(`${API}/v1/addresses`);
        url.searchParams.set("q", q);
        url.searchParams.set("limit", "8");
        const res = await fetch(url, {
          headers: { Authorization: `Bearer ${KEY}` },
          signal: controller.signal,
        });
        if (res.ok) {
          // country_code says which country was searched; note says why the
          // list is empty when empty is the right answer.
          const { data } = (await res.json()) as {
            data: Address[];
            country_code?: string;
            note?: string;
          };
          setResults(data);
        }
      } catch (err) {
        if ((err as Error).name !== "AbortError") console.error(err);
      } finally {
        if (!controller.signal.aborted) setBusy(false);
      }
    }, 200);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);

  return { results, busy };
}

02The component

A labelled input, a list of buttons, and a hidden field carrying the address id. Once an address is picked, the hook is handed an empty query so the list closes and no further requests are made until the person types again.

src/AddressSearch.tsxtsx
// AddressSearch.tsx
import { useId, useState } from "react";
import { useAddressSearch, type Address } from "./useAddressSearch";

export function AddressSearch({ onPick }: { onPick: (address: Address) => void }) {
  const id = useId();
  const [query, setQuery] = useState("");
  const [picked, setPicked] = useState<Address | null>(null);
  const { results, busy } = useAddressSearch(picked ? "" : query);

  return (
    <div>
      <label htmlFor={id}>Delivery address</label>
      <input
        id={id}
        type="search"
        autoComplete="off"
        placeholder="145 sydney road"
        value={query}
        onChange={(event) => {
          setPicked(null);
          setQuery(event.target.value);
        }}
      />
      {busy && <p>Searching…</p>}

      {results.length > 0 && !picked && (
        <ul role="listbox">
          {results.map((address) => (
            <li key={address.id}>
              <button
                type="button"
                onClick={() => {
                  setPicked(address);
                  setQuery(address.formatted);
                  onPick(address);
                }}
              >
                {address.formatted}
              </button>
            </li>
          ))}
        </ul>
      )}

      {/* The id is what your form should submit, not the text. */}
      <input type="hidden" name="address_id" value={picked?.id ?? ""} />
    </div>
  );
}

Store id. It is the address's id in its country's register, which in Australia is the G-NAF Address Detail PID, sent under address_detail_pid as well. It is stable across releases for an address that has not changed, where the formatted line is not, so it is the thing to key your own records on rather than the text.

03Use it

The parent gets the whole address: id, coordinates, mesh block and every G-NAF component, from one call.

src/CheckoutForm.tsxtsx
import { AddressSearch } from "./AddressSearch";

export function CheckoutForm() {
  return (
    <form method="post" action="/checkout">
      <AddressSearch
        onPick={(address) => {
          console.log(address.id, address.lat, address.lng, address.components.postcode);
        }}
      />
      <button type="submit">Continue</button>
    </form>
  );
}

Next

Starting from nothing? Stand up a React site with address search walks from an empty folder to a deployed page using these two files.

Related