Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * idle        — supported, nothing in flight, and permission is either granted or unknown.
 * prompt      — the Permissions API says "never asked": calling `request()` opens the system dialog.
 *               This is the signal an explain-before-you-ask card (feedback/permission-prompt) waits on.
 * loading     — one `getCurrentPosition` or one `watchPosition` is out, no fix back yet.
 * ready       — `coords` holds a fix (in watch mode every update lands back on this state).
 * denied      — the user or the browser refused. The browser will not ask again by itself; only site settings undo it.
 * unavailable — no Geolocation API in this environment at all (SSR, a non-HTTPS page, an old browser).
 */
export type GeolocationStatus = "idle" | "prompt" | "loading" | "ready" | "denied" | "unavailable"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-geolocation.json

Prompt

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; immediate therefore defaults to false and the hook stays inert until request(). The intended pairing is an explainer card first (feedback/permission-prompt), this hook second.
  • prompt as 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; its change event keeps the status honest when the visitor edits site settings.
  • Hydration-safe capability detectionnavigator.geolocation is read through useSyncExternalStore with a false server 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 tokengetCurrentPosition cannot be aborted, so stop() and every new request() bump a monotonic token; a resolve carrying a stale token, or one arriving after mountedRef went false, is dropped instead of writing into a dead component.
  • Denied is a status, timeout is an error — only PERMISSION_DENIED moves 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 an error attached, and the last fix is never cleared, so a stale-but-valid position keeps rendering as ready instead 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 prompt means 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.

On This Page