Hooks

useWebShare

Opens the native share sheet and degrades honestly — a cancelled sheet is reported as dismissed rather than an error, files are checked with canShare first, and the fallback's own success or failure is reported as such.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * `idle`      — nothing has been tried yet.
 * `sharing`   — `share()` is out: the system sheet is open, or the fallback is running.
 * `shared`    — the share completed. **Sheet or fallback** is in the `via` that `share()`
 *               returns (or hands to `onSuccess(via)`) — a successful fallback copy counts
 *               as done, but the copy should read "link copied", not "shared".
 * `dismissed` — **the user closed the sheet themselves.** Not a failure: `error` stays
 *               `null`, `onError` does not fire, and the fallback does **not** run. See the
 *               AbortError note below.
 * `error`     — it really failed, and `error` carries the classified reason.

Installation

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

Prompt

Build a React + TypeScript "useWebShare" hook (React only — no npm
dependencies; browser Web Share API: navigator.share and navigator.canShare).

Contract
- `useWebShare({ onSuccess, onError, fallback } = {})`.
- Returns `{ isSupported, canShare, share, status, error }`.
- `status` is `"idle" | "sharing" | "shared" | "dismissed" | "error"`.
  `dismissed` is its own state: the user closed the sheet. It is NOT an error.
- `error: WebShareError | null` — non-null only while `status === "error"`.
  `WebShareError` is `{ kind, message, cause: WebShareError | null }` with
  `kind` in `"unsupported" | "insecure-context" | "invalid-data" |
  "not-allowed" | "already-sharing" | "share-failed" | "fallback-failed"`.
  `cause` is filled only for `fallback-failed`, and holds the failure that
  forced the degradation. UI copy branches on `kind`; `message` is the
  browser's own, unlocalised text.
- `share(data: ShareData): Promise<WebShareResult>` where `WebShareResult` is
  `{ outcome: "shared", via: "web-share" | "fallback" }` | `{ outcome:
  "dismissed" }` | `{ outcome: "error", error }`. It never throws — every
  ending is in the return value. Referentially stable.
- `canShare(data: ShareData): boolean` — a runtime probe, referentially
  stable. It reads `navigator` when called, so it must not be called during
  render (see the capability-detection rule below).
- `isSupported` — `navigator.share` exists. `false` during SSR and on the
  hydrating first paint.
- `onSuccess(via)` fires for both routes, so consumers can say "Copied link"
  instead of "Shared". `onError(error)` fires only for real failures.
  `fallback(data, reason)` runs when the share is impossible or failed.
  All three are held in latest-refs and never enter a dependency array.

Behavior
- A cancelled share sheet rejects with `AbortError`. Map it to
  `outcome: "dismissed"`: leave `error` at null, do NOT call `onError`, and do
  NOT run the fallback — the user just said "no", so copying a link behind
  their back is worse than doing nothing. Treating AbortError as a failure is
  the single most common bug with this API: every "Cancel" press would raise a
  "Share failed" toast. Honest caveat to document: Chromium also reports some
  internal share-service faults as `AbortError`, and a page cannot tell them
  apart, so they land in `dismissed` too.
- Web Share requires transient user activation, otherwise `NotAllowedError`.
  So every pre-check is synchronous and `navigator.share(data)` is called
  BEFORE the function's first `await`, which keeps `onClick={() =>
  share(data)}` valid. (Verified in a harness: a counter incremented by a
  stubbed `navigator.share` already reads 1 on the statement right after
  `share()` returns its promise.) Document that the consumer must not `await`
  anything else before calling it.
- Pre-checks, in order, all synchronous:
  1. no `navigator.share` → `unsupported`, unless `window.isSecureContext ===
     false`, which yields `insecure-context` instead. Web Share is a
     secure-context API, so on http:// the whole API is simply missing; saying
     "unsupported browser" there sends the user off to install another browser
     when the real fix is the URL.
  2. `canShare(data)` false → `invalid-data`, with a different message when the
     payload carries files. Never call `share({ files })` without asking
     `canShare` first: file sharing is gated behind a MIME allowlist and is far
     narrower than text/URL sharing, and an unsupported file throws a
     `TypeError` instead of rejecting.
- `canShare` implementation: `navigator.canShare` and `navigator.share` shipped
  separately, so when only `share` exists, allow text payloads and refuse
  anything carrying files — an unverifiable file share is not worth the thrown
  TypeError. Wrap the call in try/catch (the spec says it returns false, but
  implementations have thrown on malformed payloads).
- Failure classification from the rejection's `name`: `NotAllowedError` →
  `not-allowed` (no user gesture, or a `web-share` permissions policy),
  `TypeError` → `invalid-data`, `InvalidStateError` → `already-sharing`,
  anything else → `share-failed`.
- The fallback is a MAIN ROUTE, not decoration: desktop Firefox has no
  `navigator.share` at all. It runs for every pre-check failure and every real
  error, never for a dismissal. Its own outcome is reported honestly:
  returning `false`, or throwing synchronously, or rejecting, produces
  `fallback-failed` with `cause` set to the original reason; anything else
  produces `outcome: "shared", via: "fallback"`. Await the fallback inside a
  try/catch in an async function so a synchronous throw is caught too —
  `Promise.resolve(fn())` would let a synchronous throw escape before the
  promise ever sees it. Document the footgun that a fallback returning
  `undefined` counts as success, so consumers should return the clipboard
  helper's boolean instead of discarding it.
- With no fallback, the original error is returned as-is.
- Concurrency: keep a `pendingRef`. A second `share()` while one is in flight
  returns an `already-sharing` error WITHOUT touching `status`, without calling
  `onError` and without running the fallback — the open sheet still owns the
  status, and flashing an error (or quietly copying a link) underneath it would
  be worse. A browser-reported `InvalidStateError` maps to the same `kind`.
- Capability detection never happens during render: `isSupported` goes through
  `useSyncExternalStore(noopSubscribe, detect, () => false)` so SSR and
  hydration agree. `canShare` stays a call-time probe; when a render-time
  decision is needed (disabling a "Share file" button), the CONSUMER caches one
  probe result and feeds it through `useSyncExternalStore` as well — a snapshot
  must be stable, so it has to be cached.
- `status` and `error` live in one state object so they can never disagree.
  Entering `sharing` clears `error`.
- After the `await`, re-check a `mountedRef` (set to true INSIDE the mount
  effect, not only cleared in cleanup — StrictMode's mount → cleanup → mount
  would otherwise leave a live instance marked unmounted): skip the setState
  and the consumer callbacks when the component is gone, but still return the
  result to whoever is awaiting.

Rendering & styling
- The hook renders nothing. The rule for the consumer's UI is: never show one
  outcome dressed as another. Three visually distinct endings — a success line
  whose wording depends on `via`, a MUTED "Cancelled" line for `dismissed`
  (`text-muted-foreground`, no destructive tone, no toast), and a destructive
  block for `error` carrying the `kind` as a badge plus a per-kind recovery
  hint ("this browser has no share sheet", "the page is on http://", "allow
  clipboard access").
- Semantic tokens only: `bg-card`, `bg-muted/40`, `text-muted-foreground`,
  `border`, and `border-destructive/40 bg-destructive/10 text-destructive`
  (plus `dark:bg-destructive/20`) for the failure block.
- Put the result panel in an `aria-live="polite"` region: the outcome arrives
  long after the click, and the share sheet steals focus while it is open.
- A "share a file" button that cannot work should be genuinely `disabled` with
  the reason rendered next to it — an enabled button that throws a TypeError is
  a lie. Keep that reason visible rather than hiding it in a `title`.
- Long share URLs and browser error messages need `break-words`. Watch flex
  containers: every inline child of a `flex` row becomes a flex item, so a
  paragraph mixing prose with code spans must wrap all of it in ONE
  `min-w-0 break-words` span, otherwise the pieces line up in a single
  un-wrapping row (measured: 122px of horizontal overflow at a 390px viewport).

Customization levers
- `fallback` — the whole degradation strategy. Copy the link (return the
  clipboard helper's boolean), open a `mailto:`, or open your own share menu;
  return `false` or throw when it did not work.
- `onSuccess` / `onError` — where a toast, an analytics event, or a "Copied!"
  affordance hangs off. Read `via` to pick the wording.
- The payload — `{ title, text, url }` for a page, `{ files }` for an export or
  a screenshot, or both; `canShare` is what tells you whether the richer
  payload is safe here.
- Want an auto-reset ("Copied!" fading back to idle)? Wrap `status` in a timer
  on the consumer side; the hook keeps the last outcome on purpose so a panel
  can keep explaining it.
- Want the dismissal louder or quieter? It is a separate state precisely so you
  can decide — most apps show nothing at all for `dismissed`.
- Multi-target UI (WhatsApp / X / email columns) is not this hook's job: use it
  as the "native share" entry and let a menu component own the rest.

Concepts

  • Cancel is not a failure — closing the share sheet rejects with AbortError, which becomes its own dismissed state: error stays null, onError never fires, and the fallback does not run. A catch that treats every rejection as a failure turns each "Cancel" press into a "Share failed" toast, and a fallback wired into that catch quietly copies a link the user just declined to share. Documented caveat: Chromium reports a few internal share-service faults as AbortError too, and a page cannot tell them apart.
  • Gesture-preserving call — Web Share needs transient user activation, so every pre-check is synchronous and navigator.share() is dispatched before the hook's first await. Measured in a harness with a stubbed navigator.share: the call counter already reads 1 on the statement right after share() returns its promise. Anything the consumer awaits before calling share() still loses the activation and earns a NotAllowedError.
  • Ask before you share files — file sharing rides a MIME allowlist and is much narrower than text or URL sharing, and handing files straight to share() throws a TypeError rather than rejecting. So canShare(data) is checked first, and when a browser exposes share but not canShare, file payloads are refused rather than gambled on.
  • Honest degradation — the fallback is the main route on desktop Firefox, not an edge case, and it reports its own outcome: false or a throw becomes fallback-failed (with cause naming what forced the degradation), anything else becomes shared with via: "fallback", so the UI can say "Copied link" instead of "Shared". A fallback returning undefined reads as success — return the clipboard helper's boolean.
  • Capability detection outside renderisSupported comes from useSyncExternalStore with a false server snapshot, so SSR and hydration agree; canShare stays a call-time probe, and a render-time decision such as disabling a "Share file" button is made by caching one probe result and feeding it through useSyncExternalStore too (a snapshot has to be stable).
  • One owner per sheet — a second share() while a sheet is open returns already-sharing without touching status, without calling onError and without running the fallback, because the in-flight share still owns the visible state; the same kind is used when the browser reports the collision itself as InvalidStateError.

On This Page