Hooks

usePermission

Subscribes to navigator.permissions for one name or a whole panel of them — query only, never request — folding Firefox's rejections, Safari's gaps, unknown names and insecure origins into one honest unsupported state with a reason.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Names accepted by `navigator.permissions.query()`.
 *
 * The DOM lib's own `PermissionName` union is narrower than reality (it has no
 * `clipboard-read`, for instance) and every engine ships a *different* subset of
 * the registry, so this type is deliberately open: the literals give
 * autocomplete, the `(string & {})` arm lets a newer name through without a cast
 * or a library upgrade. An unknown name is not a type error — it is a runtime
 * `TypeError`, which this hook reports as `state: "unsupported"`.
 */

Installation

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

Prompt

Build a React + TypeScript "usePermission" hook (React only — no npm
dependencies; browser Permissions API).

Contract
- Two overloads over one implementation:
  `usePermission(name)` → one result object;
  `usePermission([name, ...])` → `Record<name, result>` with the argument's
  literal names as keys (generic `<N extends PermissionQueryName>` so
  `perms.camera` type-checks and `perms.typo` does not).
- A result is `{ state, isSupported, error, query }`.
- `state` is `"granted" | "denied" | "prompt" | "unsupported" | "unknown"`.
  granted/denied/prompt come verbatim from `PermissionStatus.state`;
  `unknown` means the query has not answered yet; `unsupported` means this
  environment cannot answer for this name at all.
- `error` is `{ reason, name, message }`, non-null only while `state` is
  `"unsupported"`. `reason` is `"no-api"` (navigator.permissions missing),
  `"unknown-name"` (the engine rejected the name with a TypeError) or
  `"query-rejected"` (the name was understood, the query still failed).
  `name` is the rejection's own name ("TypeError", "NotSupportedError", …) and
  `message` is the engine's raw text — every engine words it differently, so
  UI copy must branch on `reason` and never on `message`.
- `isSupported` is "this environment can answer for this name": false once the
  API is missing or the query rejected. While `state` is `"unknown"` it is
  optimistic (it only means the API exists).
- `query()` re-reads that one permission on demand and is referentially stable
  for as long as the name stays in the argument.
- The accepted-name type is deliberately open: a union of the known literals
  plus `(string & {})`, because the DOM lib's own `PermissionName` is missing
  entries (no `clipboard-read`) and every engine ships a different subset. An
  unknown name is a runtime `TypeError`, not a compile error.

Behavior
- QUERY ONLY, NEVER REQUEST. `navigator.permissions.query()` raises no dialog,
  which is exactly why a whole panel of names can be read on mount. Asking has
  to be done by the capability's own API from a real user gesture
  (`Notification.requestPermission()`, `getUserMedia()`,
  `getCurrentPosition()`), and this hook must not touch any of them — a
  permission dialog is a one-shot budget, and a hook that spends it on mount is
  a trap.
- The browser differences ARE the feature. Firefox rejects `camera` /
  `microphone` outright instead of answering "denied"; Safari answers for only a
  short list of names; any name outside an engine's enum throws a `TypeError`;
  a non-secure origin does not expose `navigator.permissions` at all (the API is
  secure-context only). Every one of those must land on
  `state: "unsupported"` with the matching `reason` — the consumer never writes
  a try/catch and never sees an unhandled rejection.
- `unsupported` must stay a separate state from `denied`. They demand opposite
  copy: a denied permission has a settings page that fixes it, an unanswerable
  one does not, and a "go to site settings" walkthrough for something Firefox
  will never report is pure user-hostility.
- Wrap the call as `new Promise(resolve => resolve(navigator.permissions.query(
  { name })))`. `Promise.resolve(fn())` cannot catch a synchronous throw — the
  exception escapes before `Promise.resolve` ever sees a value — and engines
  have historically thrown synchronously on a bad descriptor.
- Subscribe to each resolved `PermissionStatus`'s `change` event so flipping the
  site setting updates the UI with no reload, and `removeEventListener` on
  unmount **and** whenever the requested names change. Also expose `query()`:
  several engines stay silent when a permission is *revoked*, so a manual
  re-read is the only honest escape hatch.
- Normalise the argument to a joined string key during render and derive the
  name list from that key inside a `useMemo`. Consumers write inline arrays
  (`usePermission(["camera", "microphone"])`), whose identity changes every
  render; feeding that straight into the effect's dependency array re-queries
  and re-subscribes on every render — measured at 66 `query()` calls and 65
  listener attach/detach cycles across 30 renders, versus 2 and 2 once
  normalised. Deduplicate while normalising so a repeated name is queried once.
- Capability detection must NOT happen during render: reading `navigator`
  while rendering breaks hydration. Use
  `useSyncExternalStore(noopSubscribe, detect, () => false)`. The server (and
  the hydrating first paint) therefore report `state: "unsupported"` with
  `isSupported: false` — the honest answer where there is no navigator — and
  React swaps in the truth immediately after hydration.
- The query is async, so late resolutions must not land: keep a `mountedRef`
  set to `true` **inside** the mount effect body (only clearing it in cleanup
  leaves it false for the live instance under StrictMode's mount → cleanup →
  mount) plus a per-effect `cancelled` flag, and drop any resolution that
  arrives after either flips.
- The state updater must be pure (StrictMode double-invokes it) and must bail
  out when the entry is unchanged, so a `change` event re-reporting the same
  state does not re-render every consumer.

Rendering & styling
- The hook renders nothing. Consumers branch on `state`: a neutral badge for
  `prompt`, a positive one for `granted`, `text-destructive` for `denied` with
  per-permission recovery steps, and a muted "your browser cannot report this"
  block for `unsupported` that offers no settings walkthrough. Use semantic
  tokens only (`bg-primary`, `text-muted-foreground`, `border`,
  `bg-destructive/10`, `text-destructive`), give any spinner
  `motion-reduce:animate-none`, and keep the raw `error.message` in a
  `font-mono break-words` block — never `break-all`, which collapses a column's
  min-content width to one character.
- Never render a request button that the hook itself powers: if the UI asks for
  something, the click handler must call the capability's own API.

Customization levers
- One name vs a list — a single gate (`usePermission("camera")`) or a whole
  settings panel (`usePermission(["geolocation", "notifications", "camera",
  "microphone", "clipboard-read"])`) from the same hook.
- Which names to include — anything the engine accepts, plus forward-compatible
  strings (`"window-management"`, `"local-fonts"`) that simply report
  `unsupported` where unknown.
- Copy per `error.reason` — the whole point of the three reasons is that they
  deserve three different sentences; translate them in one map.
- Add a `refetchOnFocus` option (re-`query()` on `visibilitychange`) if visitors
  are expected to edit permissions in another tab and your target engines do not
  emit `change` on revoke.
- Need the full descriptor (`push` with `userVisibleOnly`, `midi` with
  `sysex`)? Widen the argument from a name to a `PermissionDescriptor`; the
  rest of the state machine is unchanged, and those names currently land on
  `reason: "query-rejected"`.
- Pair it with an explainer card (`feedback/permission-prompt`) for the asking
  half: this hook decides whether the card is still worth showing.

Concepts

  • Query, never request — reading the permission store raises no dialog, so a whole panel of names can be resolved on mount; asking is a one-shot budget that only the capability's own API may spend, from a real user gesture. That split is why this hook is safe to mount on a settings page and why it never imports Notification or getUserMedia.
  • unsupporteddenied — three failures collapse into one state but keep separate reasons: no-api (secure-context-only API absent), unknown-name (the engine's enum has no such member — Firefox for camera / microphone), query-rejected (understood but refused, e.g. Chromium's push without userVisibleOnly). A denied permission has a settings page; an unanswerable one does not, and offering a walkthrough for it is worse than saying nothing.
  • Rejection-to-state, never to the caller — every rejection is caught and turned into a state, and the query is wrapped in a new Promise executor rather than Promise.resolve(...) so even a synchronous throw becomes a rejection instead of escaping. A consumer never writes a try/catch and never sees an unhandled rejection in the console.
  • Argument normalised to a string key — inline array arguments change identity every render; deriving the name list from a joined, deduplicated key is what keeps the subscription effect from re-arming. Measured in Edge: 30 re-renders cost 2 queries and 2 listeners after normalisation, 66 queries and 65 attach/detach cycles before it.
  • Live subscription plus a manual re-read — the change event keeps the UI honest when the visitor edits site settings mid-session (measured: the state flips granted → prompt with no re-render triggered by the app), while query() covers the engines that stay silent on revoke.
  • Hydration-safe detectionnavigator.permissions is probed through useSyncExternalStore with a false server snapshot instead of during render, so SSR markup and the first client paint agree on unsupported / isSupported: false, and React fills in the real answer right after hydration.

On This Page