Hooks

useCookie

A cookie-as-state hook with typed attributes, cross-instance sync, an SSR-safe default snapshot, and writes that refuse instead of silently vanishing over the 4 KB cap.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * 同 tab 内跨实例同步用的自定义事件名。cookie **没有**任何原生的变更事件
 * (Chromium 的 CookieStore 是唯一例外,下面会顺带订阅),所以同一 key 的多个
 * `useCookie` 实例只能靠这个自定义事件互相通知:谁写了谁广播一次,所有订阅者
 * 重新读一遍 jar。没有它,两个实例会各自记住自己那次写入,界面上互相打架。
 */
const SYNC_EVENT = "zyeon:cookie-sync"

/**
 * 单条 cookie 的实际上限:各家浏览器都在 4096 字节左右,而且超限**不报错**——

Installation

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

Prompt

Build a React + TypeScript "useCookie" hook (React only — it uses
document.cookie, the optional CookieStore API and CustomEvent; no npm deps).

Contract
- `useCookie(key: string, options?: UseCookieOptions): UseCookieResult`.
- `CookieAttributes = { path?: string; domain?: string; expires?: Date | number;
  maxAge?: number; sameSite?: "strict" | "lax" | "none"; secure?: boolean }`.
  `path` defaults to `"/"`; `expires` as a number means DAYS from the moment of
  the write; `maxAge` is seconds.
- `UseCookieOptions = CookieAttributes & { defaultValue?: string | null;
  maxBytes?: number }`. `defaultValue` defaults to `null`, `maxBytes` to `4096`.
- `UseCookieResult = { value: string | null;
  set(next: string | ((prev: string | null) => string), attributes?:
  CookieAttributes): CookieWriteResult;
  remove(attributes?: Pick<CookieAttributes, "path" | "domain">): void;
  refresh(): void }`.
- `CookieWriteResult = { ok: true; bytes: number } | { ok: false; reason:
  "cookies-disabled" | "too-large" | "rejected"; bytes: number; limit: number }`
  — a discriminated union, so a consumer can render the refusal instead of
  discovering it in the console.
- Values are strings, because the cookie jar has no other type. `value` is the
  DECODED string; `null` means "no such cookie" and is deliberately distinct
  from `""`, which is a real stored empty value.
- `defaultValue` is captured once on mount (same semantics as `useState`'s
  initial argument) and is reported whenever the cookie is absent.
- `set` / `remove` / `refresh` keep an identity that depends only on `key` —
  attributes are read at call time out of a latest-ref, so an inline options
  object literal does not churn them. Safe in dependency arrays.

Behavior
- The store is `document.cookie`, read through `useSyncExternalStore(subscribe,
  getSnapshot, getServerSnapshot)`. No `useEffect` + `useState` mirror, so
  there is no post-mount flash written from an effect body.
  - `getServerSnapshot` returns `defaultValue`. That is the whole SSR story:
    the server has no `document`, so the server HTML and the hydration pass
    both render the default, and React re-renders once with the real cookie
    after hydration — never a mismatch. In an SSR framework, read the cookie
    from the request headers on the server and pass it as `defaultValue`; the
    first paint is then already correct.
  - `getSnapshot` re-parses the jar on every call. That is safe here precisely
    because the value is a string: `Object.is` compares strings by value, so —
    unlike a JSON-valued storage hook — no per-key parse cache is needed to
    stop an infinite render loop.
- Parsing the jar: split `document.cookie` on `";"`, trim each pair, split at
  the FIRST `"="` only (base64 padding puts `=` inside values), compare the
  name against `encodeURIComponent(key)`, and decode the value with
  `decodeURIComponent` inside a try/catch — a hand-edited cookie can contain a
  half-written escape like `"%"`, and `decodeURIComponent` throws on it.
  Fall back to the raw text instead of throwing. First match wins; if the same
  name exists on a deeper `Path`, the browser lists that one first and never
  tells you which entry you got.
- Serializing a write, in order: `name=value` (both percent-encoded), `Path`,
  `Domain` (only if given), `Max-Age` (only if given), `Expires` (only if
  given), `SameSite`, `Secure`.
  - `Max-Age` wins over `Expires` per RFC 6265 when both are present; `remove`
    sends both so ancient browsers that only honour one still delete.
  - `sameSite: "none"` implies `Secure`; add it automatically, or the browser
    rejects the whole write.
  - A numeric `expires` is resolved as `new Date(Date.now() + days * 86400000)`
    INSIDE the write, i.e. in an event handler. Never derive it during render:
    a clock read at render time makes the server and the client disagree.
- Three refusals, checked in this order, all of them returning without
  touching the jar (the previous value stays intact) and warning once in
  development only:
  1. `"cookies-disabled"` — no `document`, or `navigator.cookieEnabled` is
     false (privacy mode, blocked third-party context).
  2. `"too-large"` — the byte length of the WHOLE serialized string, measured
     with `TextEncoder` (not `String.length`: one CJK character costs nine
     bytes once percent-encoded), exceeds `maxBytes`. Browsers cap a cookie
     around 4096 bytes and drop an oversized one SILENTLY, so this is the
     difference between a typed refusal and a value that mysteriously never
     changes.
  3. `"rejected"` — write-then-verify. After assigning `document.cookie`,
     re-read the key; if it does not come back with the value just written,
     the browser refused it. Run this check ONLY when the cookie should be
     visible at the current location (no `domain` override, and
     `location.pathname` is inside `path`), otherwise a deliberately narrower
     scope would be misreported as a failure. It is what surfaces an HttpOnly
     cookie of the same name, `secure: true` on a plain-http origin, and
     shadowing by a same-name cookie on a deeper `Path`.
- Cross-instance sync: cookies fire NO native change event. After every write
  and every removal, dispatch a `CustomEvent` on `window` carrying `{ key }`;
  `subscribe` listens for it and calls `onStoreChange` when the key matches, so
  two components calling `useCookie("locale")` never drift apart.
  Additionally feature-detect Chromium's `cookieStore` and subscribe to its
  `"change"` event — that one also fires for writes from other tabs and for
  `Set-Cookie` on a response. Where it is missing, `refresh()` is the manual
  equivalent: call it after a request whose response set the cookie.
- `remove(attributes?)` is just an already-expired write: empty value,
  `Max-Age=0` plus `Expires` at a module-level epoch constant (not a fresh
  clock read), and the SAME `Path`/`Domain` the cookie was written with —
  mismatched scope makes deletion a silent no-op, which is the single most
  common "why is it still there" bug.
- Updater form: `set(prev => ...)` receives the value read from the jar AT CALL
  TIME, not one captured during render, so two writes in the same tick compose
  instead of clobbering each other.
- Cleanup: `subscribe` returns a teardown that removes both listeners; there
  are no timers, no polling and no `rAF`. Changing `key` swaps the subscription
  because `subscribe` is memoized on `key` alone.
- HttpOnly is a hard blind spot by design: `document.cookie` does not list such
  cookies (reads are `null`) and the browser ignores a same-name write instead
  of overwriting it (writes come back `"rejected"`). Session credentials belong
  to the server; this hook owns only the half the client is allowed to touch.

Rendering & styling
- The hook renders nothing and owns no markup; consumers style their own
  surface with semantic tokens (`bg-card`, `text-muted-foreground`,
  `text-destructive` for a refusal line, `border`, `ring`) merged via `cn()` —
  no hard-coded colours, so a theme swap is free.
- Accessibility contract for the surfaces built on it:
  - A consent banner is a `role="region"` with an accessible name, not an
    `alert` — it is not an emergency; a modal cookie wall must trap focus and
    close on Escape.
  - A "Clear cookie" control that is inapplicable while the cookie is absent
    gets `aria-disabled` plus a guard that returns early in the handler —
    never the native `disabled` attribute, which blurs focus to the document
    body the moment it flips while a keyboard user is standing on it.
  - Render every refusal in text (`role="status"` or an inline
    `text-destructive` line), never console-only: the dev warning does not
    exist in production and the user is the one who loses data.
  - Group choice buttons with `aria-pressed` on the current value so the
    active option is announced, not just coloured.
- Any banner entrance animation must be gated on `prefers-reduced-motion`, and
  accepting or rejecting must work with motion off.

Customization levers
- Typed values: keep the string contract and wrap it —
  `useJsonCookie(key, fallback)` that `JSON.parse`s in a try/catch on read and
  `JSON.stringify`s on write — rather than adding a codec option; a cookie is
  a header, and keeping the encoded form visible keeps the byte budget honest.
- Byte budget: lower `maxBytes` (say 2048) if the cookie shares a domain with
  other large cookies, since the ~4 KB cap is per cookie but servers usually
  cap the whole request header line too.
- Scope: `domain: ".example.com"` to share across subdomains, a narrower
  `path` to keep a cookie out of the rest of the app, and the `__Host-` /
  `__Secure-` name prefixes for the strictest browser-enforced scoping (both
  require `secure: true`; `__Host-` additionally forbids `domain` and demands
  `path: "/"`).
- Lifetime: omit `maxAge`/`expires` for a session cookie that dies with the
  browser session; pass `expires` as a `Date` when the expiry comes from the
  server so the client clock never enters the calculation.
- Cross-tab freshness without CookieStore: call `refresh()` from a
  `visibilitychange` or `focus` listener in the consumer, or after any fetch
  whose response is known to set the cookie.
- Server pairing: read the same key in middleware / a server component and
  pass it as `defaultValue` to kill the first-paint flash; the hook's write
  path and the server's `Set-Cookie` path stay independent.

Concepts

  • Silent drop, made loud — the cookie jar has no error channel: an oversized write, a blocked jar and an HttpOnly collision all look identical to document.cookie = ... and leave the old value in place. Measuring the serialized bytes before writing and re-reading the key after writing turns all three into a typed { ok: false, reason } the UI can actually show.
  • Write-then-verify, scoped — the read-back check only runs when the write was aimed at a scope this page can see; a cookie deliberately written to a narrower Path or another Domain is invisible here by design, and treating that absence as failure would be a false alarm.
  • Broadcast instead of an event — nothing in the platform announces a cookie change outside Chromium's CookieStore, so each write publishes its own key-scoped event and every instance of that key re-reads. refresh() is the same broadcast exposed by hand, for the moment a server response changed the cookie behind React's back.
  • Absence is a valuenull means "never asked", "" means "asked, answered with nothing". Collapsing them with || is what makes consent banners reappear for people who already dismissed them; the hook uses ?? so only true absence falls back to defaultValue.
  • The default is the server snapshot — the same defaultValue that covers an absent cookie is what getServerSnapshot returns, which is why hydration cannot mismatch, and why passing the server-read cookie into it removes the first-paint flash entirely.
  • Deletion is scoped, not by name — a cookie is identified by name plus Path plus Domain; deleting with the wrong scope writes a second, already-expired cookie and leaves the original untouched, which is why remove() inherits the attributes the hook was configured with.

On This Page