Add a place search box to React

A search box over a geocoding API is more work than it looks: debounce the input, cancel the request that is already in flight, keep the list navigable by keyboard, and move the map when something is chosen. The copied component has all of that already. The hook gives you the calls and leaves the interface to you.

Install

npx @unmap/cli add geocoder

The registry item is a Combobox built from your own shadcn primitives, so it inherits your focus ring, your radius and your palette rather than arriving with opinions.

The component

The copied <Geocoder> goes inside <Map>, which is how it finds the map to fly: both read the same map-context that add installed alongside them.

import { Map } from "@/components/ui/map";
import { Geocoder } from "@/components/ui/geocoder";
 
export default function App() {
  const apiKey = import.meta.env.VITE_UNMAP_KEY as string;
 
  return (
    <div className="h-dvh">
      <Map apiKey={apiKey} center={[-114.07, 51.05]} zoom={11} className="h-full">
        <div className="absolute left-3 top-3 z-10 w-72">
          <Geocoder apiKey={apiKey} placeholder="Search Canadian places" />
        </div>
      </Map>
    </div>
  );
}

Debouncing is your job on the package path

The snippet above calls the API on every keystroke, which is wrong in a way worth being explicit about: every autocomplete request is one metered call. Typing "Calgary" unthrottled is seven calls to answer one question. The copied component debounces at 200ms; the hook deliberately does not, because a hook that decided your timing would be a hook you had to fight.

import { useEffect, useRef, useState } from "react";
import { useGeocoder } from "@unmap/react";
 
export default function DebouncedSearch() {
  const { autocomplete, results } = useGeocoder({
    apiKey: import.meta.env.VITE_UNMAP_KEY as string,
  });
  const [query, setQuery] = useState("");
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
 
  useEffect(() => {
    if (query.length < 3) return;
    clearTimeout(timer.current);
    timer.current = setTimeout(() => void autocomplete(query, { limit: 5 }), 200);
    return () => clearTimeout(timer.current);
  }, [query, autocomplete]);
 
  return (
    <div>
      <input value={query} onChange={(event) => setQuery(event.target.value)} />
      <ul>
        {results.map((result) => (
          <li key={result.id}>{result.name}</li>
        ))}
      </ul>
    </div>
  );
}

The three-character floor matters as much as the delay. One- and two-letter queries match almost everything and are worth nothing to the person typing.

What comes back

Each result carries name, lng, lat, a layer saying what kind of thing it is, and an address breakdown when the row has one. Bilingual rows also carry names, so a Montréal street can show both forms without a second lookup, and lang decides which one name holds.

Results are ranked, not filtered. The list is what the corpus thinks best matches the text, and for a short query the top result can be a city when the user meant a street in it.

Common problems

Every keystroke is a request. See above. This is the single most expensive mistake available on this page.

The map does not move on the registry path. <Geocoder> is outside <Map>, so it has no map to fly. It must be a child.

Results feel wrong for a street query. Add more of the address. "17 Ave SW" alone is ambiguous across the country; "17 Ave SW Calgary" is not.