Geomelon React
Headless React hook and component for city autocomplete, built on the Geomelon API's free, keyless oneshot prefix search (oneshot.geomelon.dev) — no API key, no backend proxy required.
Looking for other ways to integrate? See all official libraries at geomelon.dev/libraries.
Installation
npm install geomelon-react
react (>=16.8, for hooks) is a peer dependency — bring your own version. This package depends on geomelon, the zero-dependency Geomelon TypeScript client, for the actual fetch/retry/error logic.
Quick start
The hook
import { useState } from 'react';
import { useCityAutocomplete } from 'geomelon-react';
function CityField() {
const [query, setQuery] = useState('');
const { results, loading, error } = useCityAutocomplete({
countryIso: 'es',
lang: 'es',
query,
});
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Start typing a city..." />
{loading && <span>Searching…</span>}
{error && <span>{error.message}</span>}
<ul>
{results.map((city) => (
<li key={city.id}>{city.emoji} {city.name}</li>
))}
</ul>
</div>
);
}
useCityAutocomplete debounces (150ms default), cancels the in-flight request whenever countryIso/lang/query change again, and never fires below minLength (default 1). Errors are typed GeomelonError, not silently swallowed into an empty result list.
The component
For a ready-made input + dropdown with keyboard navigation and ARIA combobox semantics, use <CityAutocomplete> — it's headless (no shipped CSS), so style it with className/inputClassName/listClassName/optionClassName:
import { CityAutocomplete } from 'geomelon-react';
<CityAutocomplete
countryIso="es"
lang="es"
placeholder="Start typing a city..."
onSelect={(city) => console.log(city.name)}
inputClassName="my-input"
listClassName="my-dropdown"
optionClassName={(highlighted) => (highlighted ? 'my-option my-option--active' : 'my-option')}
/>
Keyboard: ArrowUp/ArrowDown move the highlighted option, Enter selects it, Escape closes the list. Mouse click and hover work the same way. renderOption(city, isHighlighted) overrides the default "🇪🇸 Barcelona (Barcelona)"-style row.
Configuration
Both the hook and the component accept:
| Option | Default | Notes |
|---|---|---|
countryIso |
— | ISO 3166-1 alpha-2 country code, e.g. "es" |
lang |
— | BCP 47 language code, e.g. "es" |
debounceMs |
150 |
Delay before firing a request after the query stops changing |
minLength |
1 |
Minimum trimmed query length before a request fires |
apiKey |
— | Optional RapidAPI key — omit to use the free keyless host |
freeOneshot |
false |
Route to the free keyless host even when apiKey is set |
<CityAutocomplete> additionally takes onSelect, placeholder, renderOption, and the four *ClassName styling props above.
Accessibility
<CityAutocomplete> follows the ARIA combobox pattern: role="combobox" on the input with aria-expanded/aria-controls/aria-activedescendant, role="listbox"/role="option" on the results. If you build your own UI on top of the raw hook, replicate these roles yourself — useCityAutocomplete only manages data, not markup.
Recipes
These are copy-paste starting points, not shipped code — no shadcn/ui or react-hook-form dependency is added by this package.
shadcn/ui combobox
import { useState } from 'react';
import { useCityAutocomplete } from 'geomelon-react';
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty } from '@/components/ui/command';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
function CityCombobox({ onSelect }: { onSelect: (name: string) => void }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const { results, loading } = useCityAutocomplete({ countryIso: 'es', lang: 'es', query });
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button role="combobox" aria-expanded={open}>{query || 'Select a city...'}</button>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command shouldFilter={false}>
<CommandInput value={query} onValueChange={setQuery} placeholder="Search cities..." />
<CommandList>
{!loading && results.length === 0 && <CommandEmpty>No cities found.</CommandEmpty>}
{results.map((city) => (
<CommandItem
key={city.id}
onSelect={() => {
setQuery(city.name);
setOpen(false);
onSelect(city.name);
}}
>
{city.emoji} {city.name}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
react-hook-form
import { Controller, useForm } from 'react-hook-form';
import { CityAutocomplete } from 'geomelon-react';
function Form() {
const { control, handleSubmit } = useForm({ defaultValues: { city: '' } });
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<Controller
name="city"
control={control}
render={({ field }) => (
<CityAutocomplete
countryIso="es"
lang="es"
onSelect={(city) => field.onChange(city.name)}
/>
)}
/>
<button type="submit">Submit</button>
</form>
);
}
Example
A minimal runnable app lives in examples/vite-react — clone the repo and npm install && npm run dev, or open it directly in StackBlitz.
Error handling
GeomelonError (re-exported from geomelon) is set on the hook's error field — status, body, url, and an isRateLimited getter (status === 429) are available for any non-OK response or network failure.
Types
OneshotCityDto (re-exported from geomelon): { id, name, population, emoji, en? } — en is omitted when the requested language is English or no English translation exists.
Build
npm install
npm run build # dual CJS + ESM via tsc
npm test # node:test + @testing-library/react, jsdom via global-jsdom
License
MIT