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 nothing has failed yet).
- `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,
  and prefix every name with the separator before joining so `[]` and `[""]`
  produce different keys — an empty-string name (a `usePermission(selected)`
  whose selection still defaults to `""`) gets a result like any other unknown
  name instead of `undefined`.
- Capability detection must NOT happen during render: reading `navigator`
  while rendering breaks hydration. Use
  `useSyncExternalStore(noopSubscribe, detect, () => null)` and map `null` to
  `state: "unknown"`. The server (and the hydrating first paint) have not
  been able to ask yet, which is exactly what `unknown` means — reporting
  `unsupported` there would flash "your browser cannot report this" on every
  SSR load in browsers that do have the API. Only a client snapshot of
  `false` becomes `unsupported` with `reason: "no-api"`; 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.
  • unsupported ≠ denied — 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 detection — navigator.permissions is probed through useSyncExternalStore with a null server snapshot instead of during render, so SSR markup and the first client paint agree on unknown ("not answered yet"), and React fills in the real answer right after hydration. unsupported / no-api only ever comes from the client's own probe.

On This Page