TL;DR
stx ships a great useLocalStorage(key, defaultValue) composable — returns a signal, syncs to storage on .set(), cross-tab via the storage event, auto-cleans on scope destroy. There's no equivalent for cookies. Every app I've built ends up open-coding the same document.cookie.match(/(?:^|; )name=([^;]*)/) regex in 4+ places and the same document.cookie = \name=value; path=/; max-age=...; SameSite=Lax`` write side, none of which is reactive.
A useCookie(name, options?) composable that mirrors useLocalStorage's ergonomics would let users delete a real chunk of boilerplate and get reactivity for free.
Current state
# Inventory in one of my own apps (bench-review):
$ grep -rnE 'document\.cookie' resources/
resources/stores/auth.ts:43: document.cookie = `${AUTH_COOKIE}=${encodeURIComponent(token)}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`
resources/stores/auth.ts:48: document.cookie = `${USER_COOKIE}=${encodeURIComponent(JSON.stringify(user))}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`
resources/stores/auth.ts:53: document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`
resources/stores/auth.ts:54: document.cookie = `${USER_COOKIE}=; path=/; max-age=0; SameSite=Lax`
resources/stores/auth.ts:59: const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
resources/stores/follows.ts:21: ? document.cookie.match(/(?:^|; )auth-token=([^;]*)/)
resources/stores/profile.ts:43: ? document.cookie.match(/(?:^|; )auth-token=([^;]*)/)
resources/stores/reviews.ts:135: ? document.cookie.match(/(?:^|; )auth-token=([^;]*)/)Same regex in 4 different stores, all reading the same auth-token cookie. None of them are reactive — they all read at call time and the auth UI doesn't auto-refresh if the cookie changes (logout in another tab, refresh from server, etc.).
Why useLocalStorage isn't a fit
useLocalStorage JSON-stringifies values and doesn't speak cookie attributes (path, domain, max-age, SameSite, Secure, HttpOnly, expires). Cookies are also subject to a different propagation model — no storage event, but each request round-trip can update them from the server. So this needs its own composable, not a generic-storage refactor.
Proposed API
Mirror useLocalStorage plus a small options bag for the cookie-specific attributes:
interface UseCookieOptions {
defaultValue?: string
path?: string // '/'
domain?: string
maxAge?: number // seconds; sets `Max-Age=N`
expires?: Date // sets `Expires=...` if maxAge not given
sameSite?: 'Strict' | 'Lax' | 'None' // 'Lax'
secure?: boolean // auto: location.protocol === 'https:'
encode?: (v: string) => string // encodeURIComponent
decode?: (v: string) => string // decodeURIComponent
}
function useCookie(name: string, options?: UseCookieOptions): Signal<string>Usage in app code:
const authToken = useCookie('auth-token', { maxAge: 60 * 60 * 24 * 30, sameSite: 'Lax' })
// reactive read:
authToken()
// write:
authToken.set(newToken) // writes `auth-token=<encoded>; path=/; max-age=...; SameSite=Lax`
// delete:
authToken.set('') // writes `auth-token=; path=/; max-age=0; SameSite=Lax`The signal value is always a string (deserialise to JSON in user-land if needed). Mirrors useLocalStorage's simplicity.
Implementation sketch
function useCookie(name: string, opts: UseCookieOptions = {}): Signal<string> {
const encode = opts.encode ?? encodeURIComponent
const decode = opts.decode ?? decodeURIComponent
const read = (): string => {
if (typeof document === 'undefined') return opts.defaultValue ?? ''
const m = document.cookie.match(new RegExp(`(?:^|; )${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=([^;]*)`))
return m ? decode(m[1]) : (opts.defaultValue ?? '')
}
const s = state(read())
function serialise(value: string): string {
const parts = [`${name}=${value ? encode(value) : ''}`]
parts.push(`path=${opts.path ?? '/'}`)
if (opts.domain) parts.push(`domain=${opts.domain}`)
if (value === '') parts.push('max-age=0')
else if (typeof opts.maxAge === 'number') parts.push(`max-age=${opts.maxAge}`)
else if (opts.expires) parts.push(`expires=${opts.expires.toUTCString()}`)
parts.push(`SameSite=${opts.sameSite ?? 'Lax'}`)
const secure = opts.secure ?? (typeof location !== 'undefined' && location.protocol === 'https:')
if (secure) parts.push('Secure')
return parts.join('; ')
}
effect(() => {
if (typeof document === 'undefined') return
document.cookie = serialise(s())
})
return s
}Open question on cross-tab propagation: cookies don't fire a storage event. Options:
- Skip cross-tab reactivity (acceptable — explicit
.set()covers within-tab; cross-tab navs read fresh on next mount) - Poll
document.cookieonvisibilitychange(cheap, catches most real cases — user backgrounds the tab and logs out elsewhere, returns to find their session reflected) - Use a
BroadcastChannelif the consumer opts in viaoptions.broadcast: true
I'd ship (1) for v1 and let consumers opt into more.
Why this matters in stx specifically
Cookies are how stx's own auth middleware reads sessions server-side (storage/framework/defaults/app/Middleware/Auth.ts reads bearer token from header but the SPA stores it in a cookie). Every framework consumer that hand-rolls cookie reads for the SPA side ends up writing the same regex four ways. A standard composable on the client side that interoperates cleanly with the server cookie reader closes the loop.
Related
pantry/@stacksjs/stx/dist/signals.js:2780-2793—useLocalStorageas the reference shape- See also
stacksjs/stacks#1851(the broader Request-types cleanup) —useCookiewould also reduce the cast surface there sincerequest.cookie('name')(server-side) anduseCookie('name')(client-side) would mirror each other and both be properly typed
🤖 Filed via Claude Code