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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-notification.jsonPrompt
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.Notificationexisting does not mean it works: Android Chrome'snew Notification()throwsTypeErroroutright (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, flipisSupportedto false for every instance on the page, fireonError({ 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'snotifications+change, falls back to a 3s poll when there is no Permissions API, andrequestPermission()pushes once more when it returns; getServerSnapshot returns"unsupported", so SSR and the hydrating first paint agree andNotification.permissionis never read during render. One subscription per page, shared by N instances. - tag → instance map —— a new notification with the same
tagreplaces 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, orclose(tag)closes something that no longer exists and the old listeners leak; the old instance's latecloseevent 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, soclose()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
skipWhenVisibledefaults to true andnotify()returnsnulloutright; the same event should become an in-page toast instead (feedback/toast-stack). The decision readsdocument.visibilityStateat the momentnotify()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 callingclose(), which narrowsonCloseto "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.
useHash
An SSR-safe hook that reads and writes the URL hash as shareable UI state, using pushState/replaceState instead of assigning location.hash.
useOptimistic
Keyed optimistic updates for plain props and plain event handlers — props always reclaim, sync throws become rejections, per-key de-duplication, out-of-order resolves lose, and failures are readable.