Hooks

useNotification

Sends desktop notifications behind a gesture-gated requestPermission() that never throws, keeps the permission state live, registers every notification by tag so close(tag) works, and skips notifications while the page is visible.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * The three values of `Notification.permission`, plus `"unsupported"`.
 *
 * `"unsupported"` covers server rendering and every environment with **no Notification API
 * at all**: insecure contexts (http:// pages), old browsers, iOS Safari outside a
 * home-screen install. It is not the same thing as `"denied"` —— the former has no setting
 * to change, the latter the user can allow in site settings.
 */
export type NotificationPermissionState = "default" | "granted" | "denied" | "unsupported"

Installation

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

Prompt

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

Contract
- `useNotification({ onClick, onClose, onError, skipWhenVisible = true,
  closeOnUnmount = true } = {})`.
- Returns `{ permission, isSupported, requestPermission, notify, close }`.
- `permission` is `"default" | "granted" | "denied" | "unsupported"`: the
  platform's own three values plus `"unsupported"` for "there is no
  Notification API here at all" (SSR, an insecure http:// page, iOS Safari in
  a normal tab). Never fold `unsupported` into `denied` — only one of them has
  a site-settings page worth pointing the user at.
- `isSupported` is `permission !== "unsupported"` minus a sticky
  construction-failed flag (see Behavior).
- `requestPermission(): Promise<NotificationPermissionState>` — never rejects.
- `notify(title, options?): Notification | null`, where `options` is
  `NotificationOptions` (`body`, `tag`, `icon`, `requireInteraction`,
  `silent`, `data`, …) plus a per-call `skipWhenVisible` override.
- `close(tag?)` closes the notification registered under that tag, or every
  notification this hook instance created when the tag is omitted.
- `requestPermission` / `notify` / `close` are referentially stable for the
  lifetime of the hook, so they are safe inside dependency arrays.
- The three callbacks: `onClick(event, notification)`,
  `onClose(event, notification)`, and `onError({ reason, cause, notification })`
  where `reason` is `"construct-failed" | "error-event"`. The error callback
  takes one object instead of an (event, notification) pair because a failed
  construction has neither an Event nor a Notification to hand over.

Behavior
- NEVER request permission on mount, and never expose an `immediate` option
  that would. A notification prompt is a one-shot budget per origin: spend it
  on page load and most visitors press Block, after which the browser never
  asks again — there is no second chance, only a settings page most people
  will not visit. `requestPermission()` must be called from a real user
  gesture; without transient activation Firefox silently declines to prompt
  (the promise settles back at `"default"`) and Chromium may fall back to a
  quiet prompt or answer `"denied"` outright.
- `Notification.requestPermission()` has four traps and the hook has to absorb
  all of them:
  (1) it does NOT throw when the user refuses — refusal RESOLVES as
      `"denied"`, so treating it as an exception path is simply wrong; this
      hook's promise therefore never rejects and reports refusal as a value;
  (2) Safari before 16 only supports the deprecated callback form and returns
      `undefined`, so `await` on it hangs forever — pass the callback AND read
      the returned value, take whichever settles first, and guard with a
      `settled` flag;
  (3) a handful of old engines throw synchronously on an unexpected call
      shape — wrap the call in try/catch and answer with the current
      permission instead of letting the throw escape;
  (4) once denied, calling it again resolves `"denied"` immediately without
      showing anything, so the UI must switch from a "retry" button to
      site-settings recovery steps.
  On settle, push the new value into the store yourself before resolving: the
  Permissions API `change` event is neither guaranteed nor fast, and the caller
  renders the new state the moment the promise resolves. Deduplicate concurrent
  calls by returning the in-flight promise.
- Permission is live, not a one-time read: users flip it in site settings.
  Subscribe through `useSyncExternalStore` whose `subscribe` attaches
  `navigator.permissions.query({ name: "notifications" })` plus its `change`
  event, and falls back to polling `Notification.permission` every ~3s when the
  Permissions API is missing (old Safari, insecure origins) or the query
  rejects. Wrap `query()` in `new Promise(resolve => resolve(query()))` — a
  plain `Promise.resolve(query())` cannot catch a synchronous throw, because
  the throw escapes before `Promise.resolve` ever sees a value. Keep the whole
  subscription at module level so N hook instances share one listener and one
  timer, and only notify React when the value actually changed. `getSnapshot`
  returns a string primitive (never a fresh object), and `getServerSnapshot`
  returns `"unsupported"` so SSR and the hydrating first paint agree.
- Capability detection is deliberately NOT a real construction probe.
  `window.Notification` existing does not mean it works: Android Chrome throws
  `TypeError` from `new Notification()` and demands
  `ServiceWorkerRegistration.showNotification()`, and some WebKit builds expose
  the constructor while refusing it. But "just try constructing one" is not a
  probe — it would pop a real notification at the user. So: report support from
  the API's presence, wrap the first real `new Notification()` in try/catch,
  and on a throw record the verdict in a module-level flag that flips
  `isSupported` to `false` for every instance on the page, fire
  `onError({ reason: "construct-failed" })`, and never attempt construction
  again. iOS Safari in a normal tab has no `window.Notification` at all — only
  a home-screen-installed web app (16.4+) does, and there only via a service
  worker — so this hook honestly reports `"unsupported"` there.
- `notify()` returns `null` in exactly four cases and never throws: no
  Notification API; a known-broken constructor; `Notification.permission` is
  not `"granted"` (read the live property at call time, not the render-time
  snapshot — the change event may still be in flight); or the call was skipped
  because the page is visible. Only the broken-constructor case fires
  `onError`; a skip is not an error.
- `skipWhenVisible` defaults to true: if the user is looking at the page,
  another entry in the system notification centre is pure noise — that message
  belongs in an in-page toast. Read `document.visibilityState` inside
  `notify()` (an event handler), never during render, and allow a per-call
  override for the rare "always show" case. Be honest about the boundary:
  `visibilityState` answers "is this document hidden", not "is this window
  focused" — a visible-but-unfocused window still counts as visible.
- Keep a `Map` from tag to `{ notification, detach }`. Untagged notifications
  get a synthetic key (a `"\u0000"` prefix plus a counter) so they can coexist
  in the same map without an empty-string tag swallowing them all. Because the
  platform REPLACES a notification that reuses a live tag, a `notify()` on an
  existing key must detach and drop the previous entry first — otherwise the
  map holds a dead instance, `close(tag)` closes the wrong one and the old
  listeners leak. Guard the close handler with an identity check
  (`map.get(key)?.notification === notification`) so a replaced instance's late
  `close` event cannot evict its successor.
- Attach `click` / `close` / `error` listeners at creation and remove all three
  the moment the notification leaves the registry. `close()` and unmount detach
  BEFORE calling `notification.close()`, which gives `onClose` a clean meaning:
  it fires only for closures you did not initiate (the user dismissed it, or
  the platform auto-dismissed it after its display timeout).
- The hook never calls `window.focus()` itself — clicking a notification does
  not focus the page by default, and stealing focus is the consumer's decision.
  Document the idiom instead: `onClick: () => window.focus()`, optionally
  routing off `notification.data`.
- Callbacks and both booleans live in latest-refs updated in a dependency-free
  effect, so inline arrow props never re-arm anything and `notify` / `close`
  can be `useCallback([])`. Refs are written only inside effects and event
  handlers, never during render; every `setState` updater stays pure because
  StrictMode double-invokes them.
- On unmount, detach every listener and — when `closeOnUnmount` is true
  (default) — close the notifications this instance created: the component that
  owned their `onClick` is gone, and a notification whose click does nothing is
  worse than no notification. Set it to false when a notification should
  deliberately outlive its component.

Rendering & styling
- The hook renders nothing; consumers own all UI. Branch on `permission` and
  `isSupported` with four honest states: `default` (an explainer plus the ask
  button), `granted` (the feature is on), `denied` (no retry button — show
  site-settings recovery steps, because the browser will not re-prompt), and
  `unsupported` (say the environment cannot do it and why, e.g. iOS Safari
  outside a home-screen app). Never render a success state for a refusal.
- Use semantic tokens only: `text-muted-foreground`, `border`, `bg-card`,
  `bg-muted`, `bg-primary/10` + `text-primary` for the granted panel,
  `bg-destructive/10` + `text-destructive` for the blocked one. Any spinner
  needs `motion-reduce:animate-none`. Format timestamps with an explicit locale
  (`Intl.DateTimeFormat("en-US", …)`) so SSR and client agree.
- Pair it with an in-page toast for the visible case: the same event should
  become a toast when `notify()` returns null and a desktop notification when
  the tab is hidden.

Customization levers
- `skipWhenVisible` — the hook's whole opinion in one boolean. Keep it true
  for chat / job-finished messages, override per call
  (`notify(title, { skipWhenVisible: false })`) for a "test notification"
  button that must show while the user is watching, or set it false at the
  hook level for an alarm-style app where the page being open is irrelevant.
- `closeOnUnmount` — false when a notification should survive a route change.
- Tag strategy — one stable tag per channel ("build", "chat:42") gives
  replace-in-place updates and a working `close(tag)`; omitting the tag lets
  notifications stack. That choice is the difference between "5 build
  notifications" and "one that keeps updating".
- `NotificationOptions` — `body`, `icon`, `badge`, `silent`, and
  `requireInteraction` (a hint, not a guarantee: Chromium honours it on
  Windows / Linux while macOS defers to the system's banner-vs-alert setting;
  Do Not Disturb can swallow everything on any platform).
- `onClick` — `window.focus()` plus your router: put a route in
  `options.data` and read `notification.data` in the handler.
- Poll interval — the 3s fallback only runs when the Permissions API is
  unavailable; raise it if you care more about idle CPU than about noticing a
  revoked permission quickly.
- Mobile / closed-tab delivery — swap `new Notification()` for
  `ServiceWorkerRegistration.showNotification()` behind the same surface; the
  permission half of this hook stays exactly as is.

Concepts

  • One-shot permission budget —— the notification prompt is one shot per origin: once refused, the browser never prompts on its own again and requestPermission() resolves "denied" immediately. So this hook never requests on mount, and the call has to come from a real user gesture —— without one Firefox simply does not prompt and Chromium may answer denied outright. The right pairing is "an explainer card (feedback/permission-prompt) makes the case → the user presses the button → this hook does the real request".
  • A refusal is a return value, not an exception —— Notification.requestPermission() does not throw; pressing "Block" resolves as "denied". Writing it into the failure branch of a try/catch means that path never runs. Here the three engine shapes (Promise, old Safari's callback form, ancient engines' synchronous throw) collapse into one promise that never rejects.
  • The honest boundary of capability detection —— window.Notification existing does not mean it works: Android Chrome's new Notification() throws TypeError outright (it only accepts Service Worker notifications). And "just try constructing one" pops a real notification, so it is no harmless probe. Hence the strategy: answer from the API's presence first, and when the first real construction fails, record the verdict in a module-level store, flip isSupported to false for every instance on the page, fire onError({ reason: "construct-failed" }), and never retry.
  • Subscribe to permission, do not snapshot it —— the user can change it in site settings at any time. Permission goes through useSyncExternalStore: subscribe attaches the Permissions API's notifications + change, falls back to a 3s poll when there is no Permissions API, and requestPermission() pushes once more when it returns; getServerSnapshot returns "unsupported", so SSR and the hydrating first paint agree and Notification.permission is never read during render. One subscription per page, shared by N instances.
  • tag → instance map —— a new notification with the same tag replaces the old one, so the system only ever holds one. The map must therefore detach and drop the old instance before the new one is registered, or close(tag) closes something that no longer exists and the old listeners leak; the old instance's late close event also has to be blocked by an identity check, or it evicts its successor from the map. Untagged notifications enter the map under a "\u0000"-prefixed synthetic key, so close() can still clear them all at once.
  • Do not interrupt a visible page —— while the user is staring at the page, another entry in the system notification centre is pure noise, so skipWhenVisible defaults to true and notify() returns null outright; the same event should become an in-page toast instead (feedback/toast-stack). The decision reads document.visibilityState at the moment notify() is called (in an event handler, not during render); it answers "is the document hidden" rather than "is the window focused", so a window that is on screen but unfocused still counts as visible.
  • Closures you initiate do not fire onClose —— both close() and unmount detach before calling close(), which narrows onClose to "you did not close this": the user dismissed it, or the platform auto-dismissed it after its display timeout. Consumers can take it as "the user has dealt with this message" without having to work out who closed it.

On This Page