Hooks

useHash

An SSR-safe hook that reads and writes the URL hash as shareable UI state, using pushState/replaceState instead of assigning location.hash.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Event name used to notify every subscriber in this document after a hash write.
 *
 * **Why it has to exist**: `history.pushState` / `replaceState` fire **neither**
 * `hashchange` nor `popstate` per spec — only a real navigation to a new fragment
 * (clicking an anchor, Back/Forward, assigning to `location.hash`) does. This hook
 * deliberately writes with pushState (see the comment on `setHash`), so it has to
 * broadcast afterwards or not even its own subscribers would hear about it.
 *
 * **Why it hangs off `window` and not a module-level subscriber set**: this hook is

Installation

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

Prompt

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 writepushState/replaceState change the URL without firing hashchange or popstate; that is the whole reason this hook broadcasts its own event, and why a hook that only listens to hashchange goes stale the moment it stops assigning location.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 share window and 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.hash and the snapshot is a primitive string; value equality is what stops useSyncExternalStore from re-rendering forever.
  • Clean clear — setting url.hash = "" makes the URL serializer drop the fragment entirely, so clearing yields pathname + search rather 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.

On This Page