2026年7月22日
React useCookie Hook:把 Cookie 变成响应式状态(2026)
一个主题切换按钮把用户的选择存进 cookie,好让服务器在下一次请求时直接渲染出正确的主题——不闪一下错误的模式。组件写下 document.cookie = 'theme=dark',然后……什么都没有重新渲染。document.cookie 不是 React 状态:写入不会通知任何人,读取意味着解析一串分号分隔的字符串,而且它变化时没有任何事件可以订阅。每个关心这个 cookie 的组件,都在安静地读着一份过期的副本。
@reactuses/core 的 useCookie 把 cookie 变成普通的组件状态:像状态一样读、像状态一样写,同一标签页里监听同一个 key 的所有实例一起更新。它构建在 js-cookie 之上,属性处理(过期时间、路径、SameSite)是久经考验的那种。以下都是真实 API,TypeScript 优先。
手写版本,以及它在哪里散架
Cookie 比你用过的每一个框架都古老,它的 API 也毫不掩饰这一点。手写的 React 版本长这样:
function ThemeToggle() {
const [theme, setTheme] = useState(() =>
document.cookie
.split('; ')
.find((row) => row.startsWith('theme='))
?.split('=')[1] ?? 'light'
);
const update = (next: string) => {
document.cookie = `theme=${next}; path=/; max-age=31536000`;
setTheme(next);
};
// ...
}
它散架的几个常见位置:
- 字符串解析是你的问题。 按
'; '切分、前缀匹配 key、解码值——这是一个你现在要在每个组件里维护的 cookie 解析器。 - 别的地方不会更新。 两个显示同一个 cookie 的组件各自握着自己的
useState副本。一个写入了;另一个继续渲染旧值,直到某个无关的更新碰巧让它重渲染。 - 属性全靠字符串拼接。
path、expires、secure、SameSite都是手工拼接的片段——拼错了不会报错,只会悄悄产出一个作用域错误的 cookie。 - 在服务端直接崩溃。 SSR 期间不存在
document,就算加了守卫,服务端渲染和客户端首次渲染也可能不一致——hydration 不匹配。
useCookie——Cookie 即状态
import { useCookie } from '@reactuses/core';
function ThemeToggle() {
const [theme, setTheme] = useCookie('theme', { expires: 365, path: '/' }, 'light');
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
当前主题:{theme}
</button>
);
}
签名:
function useCookie(
key: string,
options?: Cookies.CookieAttributes,
defaultValue?: string
): readonly [
string | undefined, // 当前值
(value: string | undefined | ((prev) => string | undefined)) => void, // 更新
() => void // 刷新
];
三点值得注意:
- 值是字符串。 Cookie 本质是字符串传输——这个 hook 不替你猜序列化方式。要存对象?自己
JSON.stringify,或者先想想它是否真的该放在 cookie 里(每个 cookie 约 4KB 预算,而且每个字节都会跟着每次 HTTP 请求上路)。 - 设为
undefined就是删除。setTheme(undefined)直接移除 cookie——不需要额外导入一个remove函数。函数式更新也支持:setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))。 - 挂载时 cookie 不存在,默认值会被写入。 传
'light'作为默认值,首次渲染时 cookie 就会以theme=light的形式落地——这意味着服务器在下一次请求就能看到它。对主题 cookie 来说,这正是目的所在。
Cookie 属性——有类型,不拼接
options 参数原样透传给 js-cookie,所以就是完整的 Cookies.CookieAttributes:
| 属性 | 作用 |
|---|---|
expires | 从现在起的天数(365),或一个精确时刻的 Date。省略则是随浏览器关闭消失的会话 cookie |
path | 哪些路径能看到这个 cookie——几乎总是想要 '/' |
domain | 跨子域共享('.example.com') |
secure | 仅 HTTPS |
sameSite | 'strict'、'lax' 或 'none'——跨站发送策略 |
options 对象按值比较,不按引用——每次渲染都内联传 { expires: 365, path: '/' } 完全没问题,不会引起任何抖动。
一个值得知道的锋利边缘:属性是写入时的配置。浏览器不允许 JavaScript 读回一个 cookie 的 path 或过期时间——所以删除走的是你写入时用的同一组 path/domain。同一个 key 的 options 保持一致,这一点就永远咬不到你。
同步模型:同标签页、其他标签页、以及服务器
这是 cookie 与 Web Storage 真正不同的地方,这个 hook 对此很诚实。
同标签页:自动。 标签页里每一个 useCookie('theme', …) 实例都会在任何一个写入时一起更新。Cookie 没有原生的变化事件,所以 hook 在写入时派发一个内部 window 事件——兄弟组件保持同步,你什么都不用接。
其他标签页:不自动。 localStorage 有跨标签页的 storage 事件;cookie 什么都没有。别的标签页写了 cookie,这个标签页自己不会知道。跨标签页的偏好同步是 useLocalStorage 的领域——完整工具箱见《React 跨标签页状态》。
服务器(或任何外部写入):refreshCookie。 Cookie 的超能力是服务器也能写它——比如 fetch 响应里的 Set-Cookie 头。这种写入同样不触发任何客户端事件,所以元组的第三个元素用来按需重读:
const [session, , refreshSession] = useCookie('session_hint', {}, '');
const login = async (creds: Credentials) => {
await fetch('/api/login', { method: 'POST', body: JSON.stringify(creds) });
refreshSession(); // 拿到响应刚刚设置的 cookie
};
心智模型一行一个:同标签页写入自我传播;跨标签页交给 localStorage;外部写入需要 refreshCookie()。
useCookie vs useLocalStorage vs useSessionStorage
三者都让持久化的值变成响应式;区别在于谁能看到这个值、能看多久:
useCookie | useLocalStorage | useSessionStorage | |
|---|---|---|---|
| 服务器能看到 | ✅ 每次请求都带上 | ❌ | ❌ |
| 生命周期 | 你来定(expires) | 永久直到清除 | 标签页关闭 |
| 跨标签页同步 | ❌(无原生事件) | ✅ | ❌ |
| 容量预算 | 约 4KB,每次请求都发送 | 约 5MB,留在本地 | 约 5MB,留在本地 |
| 值类型 | string | 任意(类型化序列化器) | 任意(类型化序列化器) |
决策规则一句话:服务器需要这个值才能正确渲染吗? 主题、语言、同意状态、A/B 分桶——答案是”是”的,就放 cookie,因为服务器在发出第一个字节的 HTML 之前就能从请求头里读到它。如果值只属于客户端——表单草稿、面板位置、服务器从不渲染的缓存偏好——Web Storage 更宽敞,还能跨标签页同步。存储那一侧的完整指南见《React 里的 useLocalStorage》。
(把房间里的大象说明白:真正的鉴权 token 属于 HttpOnly cookie,JavaScript——包括这个 hook——根本读不到它。这是特性,不是缺陷。useCookie 面向的是可读层:偏好、提示、开关。)
真实使用场景
- 不闪烁的主题切换。 主题 cookie 随请求上行,服务器直接渲染
<html class="dark">,客户端不需要任何修正。这个场景用localStorage不可能实现——服务器永远看不到 storage。 - 语言选择。 同样的形状:用户选了语言,cookie 持久化,服务端渲染读到它,从第一个字节开始就用正确的语言响应。
- Cookie 同意横幅。 用长
expires写下同意决定;客户端代码和服务端中间件都能在加载分析脚本前检查它。 - A/B 实验分桶。 用函数式更新只分配一次(
setBucket((prev) => prev ?? assignBucket())),分桶结果对服务端渲染、边缘中间件和客户端同时可见。 - 登录后的 UI 提示。 一个非敏感的
logged_in=1提示 cookie(由服务器随真正的HttpOnly会话一起设置),让客户端立刻渲染账户相关的界面——登录调用之后refreshCookie()把它捡起来。
SSR:defaultValue 规则
服务端渲染期间没有 document.cookie,hook 什么都读不到。规则就一句话:做 SSR 时,永远传 defaultValue。 服务器渲染默认值,客户端首次渲染产出相同的标记(hydration 要求的正是这个——React 会比对两者),真正的 cookie 值紧接着在 effect 里落地。SSR 应用里省略默认值,hook 会在开发环境警告你,因为服务器(渲染空)和客户端(渲染 cookie)会不一致——hydration 不匹配。
如果你的框架能在服务端读 cookie(Next.js 的 cookies()、Remix 的 loader),还可以更进一步:把真实的请求 cookie 作为 defaultValue 传进来,首屏直接正确,不需要任何 hydration 后的修正。更宏观的模式——为什么所有浏览器 API 都需要这种纪律——见《SSR 安全的 React Hooks》。
要点回顾
document.cookie不是状态——没有响应性、要解析字符串、属性靠手拼。useCookie把它变成js-cookie加持的[value, set, refresh]元组。- 设
undefined即删除;支持函数式更新; cookie 缺失时会用你的defaultValue初始化,服务器下一次请求就能看到。 - 记住同步模型: 同标签页实例自动同步;跨标签页不行(那是
useLocalStorage的事);服务器写入的 cookie 需要显式refreshCookie()。 - 按受众选择: 服务器渲染需要的值——主题、语言、同意、A/B——放 cookie。纯客户端数据放 Web Storage。
- SSR 规则: 服务端渲染时永远传
defaultValue——更好的做法是把真实的请求 cookie 作为默认值传入。
从 @reactuses/core 拿来用,让你的 cookie 变成它一直想成为的那种状态。