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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-cookie.jsonPrompt
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
Pathor anotherDomainis 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 value —
nullmeans "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 todefaultValue. - The default is the server snapshot — the same
defaultValuethat covers an absent cookie is whatgetServerSnapshotreturns, 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
PathplusDomain; deleting with the wrong scope writes a second, already-expired cookie and leaves the original untouched, which is whyremove()inherits the attributes the hook was configured with.
usePrefersReducedMotion
An SSR-safe hook that reports whether the user has asked the system to reduce motion, and updates live when the OS setting changes.
useRetry
A retry loop for one async call: exponential backoff with full jitter, a shouldRetry predicate, a live countdown to the next attempt, and cancellation that clears every timer.