React useCookie Hook: Cookies as Reactive State (2026)
A theme toggle stores the user's choice in a cookie so the server can render the right theme on the next request — no flash of the wrong mode. The component writes document.cookie = 'theme=dark', and then… nothing re-renders. document.cookie is not React state: writing it notifies nobody, reading it means parsing a semicolon-separated string, and there's no event to subscribe to when it changes. Every component that cares about that cookie is quietly reading a stale copy.
useCookie from , so the attribute handling (expiry, path, SameSite) is the battle-tested kind. Everything below is the real API, TypeScript-first.
The Manual Version, and Where It Frays
Cookies predate every framework you've used, and their API shows it. The hand-rolled React version looks like this:
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);
};
// ...
}
The common ways it frays:
The string parsing is your problem. Splitting on'; ', prefix-matching the key, decoding values — that's a cookie parser you now maintain, per component.
Nothing else updates. Two components showing the same cookie each hold their ownuseStatecopy. One writes; the other keeps rendering the old value until something unrelated re-renders it.
Attributes are stringly-typed.path,expires,secure,SameSiteare all fragments you concatenate by hand — and a typo doesn't throw, it just silently produces a cookie with the wrong scope.
It crashes on the server.documentdoesn't exist during SSR, and even guarded, the server render and the client's first render can disagree — a hydration mismatch.
useCookie — Cookies as State
import { useCookie } from '@reactuses/core';
function ThemeToggle() {
const [theme, setTheme] = useCookie('theme', { expires: 365, path: '/' }, 'light');
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Current theme: {theme}
</button>
);
}
The signature:
function useCookie(
key: string,
options?: Cookies.CookieAttributes,
defaultValue?: string
): readonly [
string | undefined, // current value
(value: string | undefined | ((prev) => string | undefined)) => void, // update
() => void // refresh
];
Three things worth noting:
Values are strings. Cookies are a string transport — the hook doesn't guess at serialization. Storing an object?JSON.stringifyit yourself, or reconsider whether it belongs in a cookie at all (there's a ~4KB budget per cookie, and every byte rides along on every HTTP request).
Settingundefineddeletes the cookie.setTheme(undefined)removes it outright — no separateremovefunction to import. Functional updates work too:setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')).
If the cookie is missing on mount, the default is written to it. Pass'light'as the default and the cookie materializes astheme=lighton first render — which means the server sees it on the very next request. For a theme cookie, that's exactly the point.
Cookie Attributes — Typed, Not Concatenated
The options argument is passed straight to js-cookie, so it's the full Cookies.CookieAttributes shape:
| Attribute | What it does |
|---|---|
expires | Days from now (365), or a Date for an exact moment. Omit it for a session cookie that dies with the browser |
path | Which paths see the cookie — you almost always want '/' |
domain | Share across subdomains ('.example.com') |
secure | HTTPS-only |
sameSite | 'strict', 'lax', or 'none' — cross-site send policy |
The options object is compared by value, not identity — passing { expires: 365, path: '/' } inline on every render is fine and doesn't churn anything.
One sharp edge worth knowing: attributes are write-time configuration. The browser doesn't let JavaScript read a cookie's path or expiry back — so the delete path uses the same path/domain you wrote with. Keep the options consistent for a given key and this never bites you.
The Sync Model: Same Tab, Other Tabs, and the Server
This is where cookies genuinely differ from Web Storage, and the hook is honest about it.
Same tab: automatic. Every useCookie('theme', …) instance in the tab updates when any of them writes. Cookies have no native change event, so the hook dispatches an internal window event on write — sibling components stay in sync without you wiring anything.
Other tabs: not automatic. localStorage fires a cross-tab storage event; cookies fire nothing. If another tab writes the cookie, this tab won't know on its own. Cross-tab preference sync is for the full toolbox.
The server (or anything else): refreshCookie. Cookies' superpower is that the server can write them — a Set-Cookie header on a fetch response, for instance. No client-side event fires for that either, so the third tuple element re-reads the cookie on demand:
const [session, , refreshSession] = useCookie('session_hint', {}, '');
const login = async (creds: Credentials) => {
await fetch('/api/login', { method: 'POST', body: JSON.stringify(creds) });
refreshSession(); // pick up the cookie the response just set
};
That's the mental model in one line each: same-tab writes propagate themselves; cross-tab needs localStorage; external writes need refreshCookie().
useCookie vs useLocalStorage vs useSessionStorage
All three make persistent values reactive; they differ in who can see the value and for how long:
| . (And to be explicit about the elephant: real auth tokens belong in
|
|---|
SOCIAL SHARE CARD GENERATOR