useGeolocation
Reads or continuously tracks the browser's geolocation as a six-state machine, with an explicit request()/stop() pair and honest denied / unavailable / timeout branches.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-geolocation.jsonPrompt
Build a React + TypeScript "useGeolocation" hook (React only — no npm
dependencies; browser Geolocation API + Permissions API).
Contract
- `useGeolocation({ watch = false, immediate = false, enableHighAccuracy =
false, timeout = 10000, maximumAge = 0, onSuccess, onError } = {})`.
- Returns `{ coords, timestamp, error, status, isSupported, request, stop }`.
- `coords` is a plain-object snapshot of `GeolocationCoordinates`
(`latitude`, `longitude`, `accuracy`, plus nullable `altitude`,
`altitudeAccuracy`, `heading`, `speed`) — a plain object so it can be
JSON-serialised or persisted; the platform object is copied field by field.
- `timestamp` is the browser-supplied epoch ms of that fix, `null` before the
first one.
- `error` is `{ kind, code, message }` where `kind` is
`"permission-denied" | "position-unavailable" | "timeout" | "unsupported"`,
`code` is the raw `GeolocationPositionError.code` (0 when the API itself is
missing) and `message` is the browser's own text. Consumers must branch UI
copy on `kind`, never on `message` — the message is unlocalised and varies
per engine.
- `status` is `"idle" | "prompt" | "loading" | "ready" | "denied" |
"unavailable"`:
idle = supported, nothing in flight, permission granted or unknown;
prompt = the Permissions API says this site has never asked, so `request()`
will open the native dialog; loading = a one-shot or a watch is in flight
without a fix yet; ready = `coords` holds a fix; denied = blocked, the
browser will not re-ask by itself; unavailable = no Geolocation API here.
- `request()` and `stop()` are referentially stable for the lifetime of the
hook, so they are safe inside dependency arrays.
Behavior
- NEVER request on mount by default. `immediate` defaults to false because a
location prompt is a one-shot budget: fire it on page load and most visitors
press Block, after which the browser never asks again. When `immediate` is
true, put `"loading"` into the lazy `useState` initialiser and have the
mount effect only fire the API call — a synchronous `setState` inside an
effect body is what React's `set-state-in-effect` rule forbids.
- `request()` with `watch: false` calls `getCurrentPosition`; with
`watch: true` it calls `watchPosition` and keeps the subscription alive
until `stop()` or unmount. Options (`watch`, `enableHighAccuracy`,
`timeout`, `maximumAge`) are read at call time from the latest render, not
captured at mount.
- Capability detection must NOT happen during render: reading
`navigator.geolocation` while rendering mismatches the server markup on
hydration. Use `useSyncExternalStore(noopSubscribe, detect, () => false)`
so the server and the hydrating first paint both say "unsupported", then
React silently re-renders with the real client snapshot. `status` is forced
to `"unavailable"` whenever `isSupported` is false, so no `idle` frame ever
flashes on an unsupported browser.
- Permission state is queried, not guessed: after mount, call
`navigator.permissions.query({ name: "geolocation" })` (that query never
raises a dialog) and map granted/prompt/denied onto the resting status, then
subscribe to its `change` event so flipping the site setting updates the
status without a reload. If the Permissions API is absent or rejects the
name (older Safari), degrade silently: stay `idle` and learn the outcome
from the first `request()`.
- Failure mapping is per code, not one generic error state:
PERMISSION_DENIED (1) parks `status` at `denied` and clears an active watch
(a denied watch only repeats the same error forever); TIMEOUT (3) and
POSITION_UNAVAILABLE (2) attach `error` and drop `status` back to the
resting value — `ready` if a previous fix is still held (stale coords plus
an error means "the refresh failed"), otherwise `prompt`/`idle`. Calling
`request()` on an unsupported browser sets `status: "unavailable"` with
`kind: "unsupported"` and invokes `onError`; it never throws.
- Dismissing the dialog (Esc / the X) and blocking the site forever both
arrive as PERMISSION_DENIED. Assume the worst first (`denied`), then
re-query `navigator.permissions`: if it still says `prompt`, the visitor only
dismissed it, so correct the status back to `prompt` while keeping the error
attached — otherwise the UI hands out a pointless "go edit your site
settings" walkthrough for something a second press would fix. A blocked
permission that later returns to `granted` is the only transition that clears
a `permission-denied` error; `prompt` keeps it, so a failed attempt never
ends up with zero feedback. Browsers without a usable Permissions API stay
conservatively at `denied`. A permission that flips to `denied` mid-watch
(edited in site settings) also clears the active watch right there in the
permission handler — most browsers additionally deliver an error callback,
but that cannot be relied on, and a watch under a revoked permission is dead
weight either way.
- `coords` is deliberately NOT cleared when the status flips to `denied`
(permission revoked mid-session) — the last fix stays available and
documented as stale, so a map does not blank out.
- Late callbacks must never land: keep a `mountedRef` that is set to `true`
**inside** the mount effect body (only flipping it false in cleanup leaves
it false for the live instance under StrictMode's mount → cleanup → mount)
and a monotonic token bumped by every `request()` and `stop()`. A resolve
carrying a stale token, or arriving after unmount, is dropped.
`getCurrentPosition` has no cancel API, so `stop()` can only make its result
a no-op — say so in the docs rather than pretending it aborts.
- `onSuccess` / `onError` are held in latest-refs and never enter a dependency
array, so consumers can pass inline arrows (and an inline options literal)
without re-arming anything. `watch` fires `onSuccess` once per delivered
update.
- Clamp the numbers: NaN or negative `timeout` / `maximumAge` fall back to the
defaults, and both are capped at `0x7fffffff`. That cap is not cosmetic —
`PositionOptions` types them as WebIDL `unsigned long`, so handing `Infinity`
straight through can convert to **0** and produce the exact opposite of the
intent ("never time out" becoming "time out immediately", "any cached fix
will do" becoming "fresh fix only"). `0` stays legal on purpose:
`timeout: 0` with `maximumAge: Infinity` is the documented "cached fix or
fail instantly" idiom. `timeout` defaults to 10000 rather than the platform's
no-timeout default so `loading` cannot hang forever.
- Unmount clears any active watch and disconnects the permission listener.
Rendering & styling
- The hook renders nothing. Consumers own all UI and should branch on
`status`: a spinner for `loading`, coordinates for `ready`, a
`text-destructive` block for the error, and per-permission recovery steps
("open the location icon in the address bar, set Location to Allow, reload")
for `denied`, since the browser will not re-prompt. Use semantic tokens only
(`text-muted-foreground`, `border`, `bg-destructive/10`, `text-destructive`)
and give any spinner `motion-reduce:animate-none`. Never render a fake
success state while `status` is `denied` or `unavailable`; format
coordinates with an explicit locale (`Intl.NumberFormat("en-US")` /
`toFixed`) so SSR and client agree.
Customization levers
- `watch` — flip between one-shot address autofill and a live tracking
subscription; the rest of the contract is identical.
- `enableHighAccuracy` / `timeout` / `maximumAge` — the accuracy vs battery vs
latency triangle: high accuracy + long timeout for a courier map, low
accuracy + a large `maximumAge` for a "nearest store" guess that may reuse a
cached fix.
- `immediate` — set true only on a screen whose whole purpose is location
(a "find me" map), and even then prefer an explainer card first.
- `onSuccess` / `onError` — where analytics, toasts, or a reverse-geocode
fetch hang off; the hook intentionally does no geocoding itself.
- Want to know whether a watch is live? Expose the internal watch id as an
`isWatching` boolean; the default surface keeps the return shape minimal and
leaves that flag to the caller's own start/stop state.
- Need a country-level guess with no dialog at all? Do it server-side from
request headers and use this hook only for the precise, consented read.Concepts
- One-shot permission budget — a location dialog can be spent exactly once per site;
immediatetherefore defaults to false and the hook stays inert untilrequest(). The intended pairing is an explainer card first (feedback/permission-prompt), this hook second. promptas a pre-request signal — the Permissions API is queried (which never raises a dialog) purely so the UI can tell "never asked" apart from "already granted" and decide whether an explainer is still worth showing; itschangeevent keeps the status honest when the visitor edits site settings.- Hydration-safe capability detection —
navigator.geolocationis read throughuseSyncExternalStorewith afalseserver snapshot instead of during render, so the server's "unsupported" markup matches the first client paint and React swaps in the truth right after hydration. - Superseded-callback token —
getCurrentPositioncannot be aborted, sostop()and every newrequest()bump a monotonic token; a resolve carrying a stale token, or one arriving aftermountedRefwent false, is dropped instead of writing into a dead component. - Denied is a status, timeout is an error — only
PERMISSION_DENIEDmoves the state machine (and kills the watch, which would otherwise repeat the same rejection); a timeout or an unavailable position leaves the machine at rest with anerrorattached, and the last fix is never cleared, so a stale-but-valid position keeps rendering asreadyinstead of blanking the map. - Dismissed ≠ blocked — the platform reports both as code 1, so the hook assumes the worst, then re-queries the permission: a state still reading
promptmeans the dialog was only dismissed and the status is corrected back, keeping the error visible but dropping the recovery walkthrough that a second press would make unnecessary.
useResizeObserver
A callback-ref hook that measures an element's own size with ResizeObserver — selectable box model, rAF-batched or debounced commits, and no resize-loop errors.
usePageVisibility
An SSR-safe hook that subscribes to document visibility so polling, timers and video can pause when the tab goes to the background, and reports how long the user was away.