useHash
An SSR-safe hook that reads and writes the URL hash as shareable UI state, using pushState/replaceState instead of assigning location.hash.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-hash.jsonPrompt
Build a React + TypeScript "useHash" hook (React only — it uses the History,
URL and CustomEvent browser APIs; no npm dependencies).
Contract
- `useHash(options?: { raw?: boolean }): [string, SetHash]` where
`SetHash = (next: string, options?: { replace?: boolean }) => void`.
- Default read: the fragment **decoded and without the leading "#"** — an
absent fragment reads as `""`.
- `raw: true` flips both directions to verbatim mode: reads return exactly
what the browser stores (leading "#" included, percent-encoding untouched,
`""` when there is no fragment) and writes are passed through without
`encodeURIComponent` (only a leading "#" you supply is stripped). Use it
when you parse the fragment yourself ("#/a/b", "#k=v&k2=v2").
- `setHash(next, { replace = false })`: `false` pushes a new history entry,
`true` overwrites the current one.
- Server snapshot is `""` — the server has no location to read.
Behavior
- Built on `useSyncExternalStore`, not `useEffect` + `setState`. The store is
`location.hash` itself:
- `subscribe`: `hashchange` (anchor clicks, `location.hash = x`), `popstate`
(Back/Forward), a custom same-document event this hook dispatches after
its own writes, and — when the Navigation API exists — `currententrychange`
on `window.navigation`, feature-detected and optional.
- `getSnapshot`: read `location.hash`, slice off "#", `decodeURIComponent`.
- `getServerSnapshot`: `""`.
- No snapshot cache is needed (unlike a JSON-parsing storage hook): the
snapshot is a **primitive string**, so "unchanged store returns an equal
value" holds by value equality and React never sees a phantom change.
- **Write with `history.pushState` / `replaceState`, never `location.hash = x`.**
Assigning `location.hash` makes the browser scroll to the element whose id
matches the new fragment — pure noise when the fragment is a tab id — and it
offers no way to replace the current entry.
- **The hard part: `pushState` fires no `hashchange` and no `popstate`** (the
spec only fires them for real fragment navigations). So after every write the
hook dispatches its own event on `window` and every instance re-reads.
Dispatch on **`window`, not a module-level subscriber set**: this file is
copied into consumer projects, so one page can hold two independent copies
(the app installed one, a bundled internal package carries another). A
module-level set only reaches its own copy's subscribers; `window` plus a
constant event name is the only thing both copies share.
- Multiple instances need no shared store: they all read the same
`location.hash`, and one `window` event wakes all of them at once.
- Build the next URL with the URL API: `const url = new URL(location.href);
url.hash = fragment`. Assigning `""` drops the fragment entirely, so
`setHash("")` yields `pathname + search` with no dangling "#" in the address
bar.
- **Skip no-op writes** by comparing the *serialized* `url.href` with
`location.href` (not the raw argument — the browser normalizes spaces and
non-ASCII into percent-encoding, so an argument-level comparison would let a
repeated CJK value stack duplicate entries in raw mode). Without this,
clicking the already-active tab five times costs the user five Back presses.
- Pass `null` as the history state argument. Next.js App Router patches
`pushState`/`replaceState`: a state object carrying its internal marker is
treated as an internal call and skips the router's canonical-URL sync (its
next `replaceState` would then wipe your fragment); `null` takes the
"external write" branch, where Next copies its internals back in and syncs
the URL. In frameworks without such a patch, `null` is simply an empty state.
- Never throw: `decodeURIComponent` rejects malformed escapes ("#100%") and
`encodeURIComponent` rejects lone surrogates — both are caught and fall back
to the unconverted string.
- Nothing is read from `location` during render, so SSR and the first client
paint agree and there is no hydration mismatch.
- Honest limit: code that calls `history.pushState` directly, bypassing this
hook, produces no browser event at all. The Navigation API listener covers
Chromium; elsewhere such a write is only noticed at the next
`hashchange`/`popstate`.
Rendering & styling
- The hook renders nothing — it returns `[hash, setHash]`. Consumers own all
UI; style anything driven by the hash with semantic tokens (`bg-muted`,
`bg-background`, `text-foreground`, `text-muted-foreground`,
`focus-visible:ring-ring`) and keep controls keyboard-reachable. If you
scroll in response to a hash change, honour `prefers-reduced-motion` by
dropping `behavior: "smooth"`.
Customization levers
- Codec — the default treats the value as an opaque string, so `/`, `&` and
`=` are escaped. For route-shaped fragments ("#/settings/profile") or
key/value fragments, switch to `raw: true` and own the encoding, or replace
the encode/decode pair with a codec that leaves sub-delimiters intact; keep
the "never throw, fall back to the raw string" contract either way.
- Structured hash — layer `URLSearchParams` on top of raw mode to get
`#tab=billing&sort=asc`, parsing in a `useMemo` over the returned string.
- Write policy — add a `defaultReplace` option (or always replace) for
high-frequency state such as text fields and sliders, where one history
entry per keystroke would bury the Back button.
- Scroll behaviour — the hook deliberately never scrolls; if you want anchor
behaviour, call `document.getElementById(hash)?.scrollIntoView()` yourself
after a write, gated on `prefers-reduced-motion`.
- Deduplication — drop the `url.href` comparison if your app really wants one
history entry per write even when the value is unchanged.Concepts
- Silent write —
pushState/replaceStatechange the URL without firinghashchangeorpopstate; that is the whole reason this hook broadcasts its own event, and why a hook that only listens tohashchangegoes stale the moment it stops assigninglocation.hash. - Broadcast point on
window, not in the module — the shared thing must survive being copied: two independently installed copies of this file on one page sharewindowand the event-name constant, but not each other's module scope. - The store is the URL — there is no cached snapshot to keep coherent, because every instance reads the same
location.hashand the snapshot is a primitive string; value equality is what stopsuseSyncExternalStorefrom re-rendering forever. - Clean clear — setting
url.hash = ""makes the URL serializer drop the fragment entirely, so clearing yieldspathname + searchrather than a URL trailing a lonely#. - Push vs replace — push turns each state change into a Back-button step (tabs, opened details); replace keeps the URL shareable without polluting history (text input, slider, anything high-frequency).
- Opaque value, percent-encoded transport — values round-trip through
encodeURIComponent/decodeURIComponent, so Chinese and spaces survive a copy-pasted link; a hand-edited malformed escape falls back to the undecoded string instead of throwing.
useWakeLock
A screen wake lock hook that keeps the display on for as long as a task lasts — and re-acquires the lock the browser silently takes away every time the tab goes to the background.
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.