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 = {
  gnaf_pid: string;
  formatted: string;
  lat: number;
  lng: number;
  mesh_block?: string;
  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) {
          const { data } = (await res.json()) as { data: Address[] };
          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 G-NAF 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.gnaf_pid}>
              <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="gnaf_pid" value={picked?.gnaf_pid ?? ""} />
    </div>
  );
}

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.gnaf_pid, 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