This is the full developer documentation for persistent-state
# Introduction
> persistent-state keeps React state in a cookie, localStorage or sessionStorage, and makes cookie state readable on the server.
This page describes what persistent-state does, who it is for, how stable it is and where to go next.
:::caution[0.x releases may break]
persistent-state is in its 0.x series. Every 0.x minor release may contain breaking changes; patch releases never do.
Pin the minor version you depend on and read the [migration page](/help/migrating/) before you upgrade. Version 1.0.0
ships when the roadmap core is done.
:::
## What is persistent-state?
persistent-state is a small library that keeps a piece of React state in the browser's storage and restores it on the
next visit. You declare each key once with `persistentState()`, choosing where it lives: a cookie, `localStorage` or
`sessionStorage`. You then read and write it in components with `useStoredState()`.
Cookies are the one storage a server can read. With the Next.js entry point, a Server Component reads the cookie
values of the request, so the first render already matches what the visitor stored, without a flash of default state.
## Who is it for?
persistent-state is for React and Next.js applications that remember small preferences, such as a theme, a layout or a
dismissed notice, and want them to survive reloads, stay in sync across tabs and, for cookies, render correctly on the
server. It is not a global state manager and not a replacement for a database.
## How to use the docs
The docs are organized into the following sections:
- **Getting started**: Requirements, installation and a first stored value.
- **Guides**: Task-oriented pages, such as choosing a storage, using persistent-state with Next.js, schemas and
migrations, and consent.
- **API reference**: Every export, option and type.
- **How it works**: The internals.
- **Help**: Migrating from 0.2, a comparison with other libraries, the roadmap and the changelog.
Use the sidebar to navigate between sections, or search with Ctrl+K or Cmd+K.
For AI assistants and coding agents, the docs are also published as plain Markdown: [`/llms.txt`](/llms.txt) lists
them and [`/llms-full.txt`](/llms-full.txt) holds every page.
# Choosing a storage
> Compare cookie, localStorage and sessionStorage by lifetime, size, server access, tab scope, sync and privacy, and see what each renders during hydration.
This page compares the three storages a persistent-state definition can use, explains what each one renders on the
server and during hydration, and covers the cookie attributes and the limits to plan for.
Choose the storage with the `storage` option of `persistentState()`:
```ts
import { persistentState } from '@nicolopadovan/persistent-state'
export const theme = persistentState('theme', { default: 'system', storage: 'cookie' })
export const draft = persistentState('draft', { default: '', storage: 'sessionStorage' })
export const filters = persistentState('filters', { default: { status: 'open' } })
```
Without `storage`, a definition uses `localStorage`.
## Comparison
| Property | `cookie` | `localStorage` | `sessionStorage` |
| ------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------- |
| Lifetime | 365 days from each write. Change it with `expires`; `expires: 0` writes a session cookie. | Until it is removed. `expires` removes it once past. | Until the tab is closed. `expires` removes it once past. |
| Size | Usually about 4 KB per cookie, and browsers limit the number of cookies per domain. | About 5 MiB per origin, depending on the browser. | About 5 MiB per origin, depending on the browser. |
| Readable on the server | Yes: `readState`, `PersistentStateProvider` and `readCookieState`. | No. The server never reads it. | No. The server never reads it. |
| Sent to the server | With every request to a matching domain and path, including images and scripts. | Never, unless your code sends it. | Never, unless your code sends it. |
| Shared between tabs | Yes, and with subdomains when you set `domain`. | Yes, within one origin. | No. Each tab has its own copy. |
| Synced between open tabs | Yes by default, through a `BroadcastChannel`, and when a hidden tab becomes visible. | Yes by default, through `storage` events. | Never. |
| First render on the server | The stored value when the cookie is listed in a provider; otherwise the default. | The default. | The default. |
| Readable by scripts on the page | Yes. A cookie written by script cannot be `HttpOnly`. | Yes. | Yes. |
Set `sync: false` on a definition to stop it following other tabs. `sessionStorage` is never synced, whatever `sync`
says. See [Synchronization](#synchronization).
## Which storage should I use?
- **Use a cookie** for small state that the first paint depends on or that the server needs: a theme, a layout, whether a
sidebar is open, a locale or a currency. It is the only storage that can render without a flash, and it is sent with
every request, so keep the value small.
- **Use `localStorage`** for client-only state that is larger or that the server does not need: filters, drafts, dismissed
notices. It costs nothing per request. The value appears in one render after the first one, as the next section
describes.
- **Use `sessionStorage`** for state that belongs to one tab and should not leak into others: the step of a wizard in
progress or an unsaved draft.
Changing the `storage` of a key does not move what is already stored. A definition reads only its own storage, so
visitors start from the default; read the old value with a definition of the old storage and `set()` it on the new one
if you need to keep it.
## Server rendering and hydration
React compares the HTML the server rendered with the first render on the client. The snapshot that `useStoredState()`
gives React must therefore be equal on the server and during hydration, so a definition renders as follows:
| Storage | Server render | Hydration | After hydration |
| -------------------------------- | ------------------------------------------------- | ------------------------------------------ | ----------------------------------------------------- |
| `cookie`, listed in a provider | The value decoded from the provider's raw cookie. | The same raw cookie, decoded the same way. | `document.cookie`, normally the same value: no flash. |
| `cookie`, no provider value | The default. | The default. | The stored value, after one render with the default. |
| `localStorage`, `sessionStorage` | The default. | The default. | The stored value, after one render with the default. |
The server never reads Web Storage, and the client never reads storage while hydrating. Use `storage: 'cookie'` for state
that the first paint depends on, such as a theme or the open state of a sidebar, and list it in
`PersistentStateProvider`. The [Next.js guide](/guides/nextjs/) shows how. In other React server-rendering frameworks,
render `CookieValuesProvider` with the request's cookies.
## Cookies
A cookie definition writes its cookie with `document.cookie`. These options control its attributes, and each one is
applied on every write:
| Option | Default | Meaning |
| ---------- | ------------------------ | ------------------------------------------------------------------------------------------------- |
| `expires` | `365` | Days from each write, or a `Date`. `0` writes a session cookie. |
| `path` | `'/'` | The cookie's `Path`. The browser sends the cookie only for requests under it. |
| `domain` | None | The cookie's `Domain`. Without it, the cookie belongs to the current host and not its subdomains. |
| `secure` | `true` on `https:` pages | Whether the cookie carries `Secure`. |
| `sameSite` | `'lax'` | The cookie's `SameSite` attribute: `'strict'`, `'lax'` or `'none'`. |
```ts
import { persistentState } from '@nicolopadovan/persistent-state'
export const locale = persistentState('locale', {
default: 'en',
storage: 'cookie',
expires: 180,
domain: 'example.com',
sameSite: 'lax',
})
```
The cookie attributes are a type error for a definition whose `storage` is not `'cookie'`. Keep the default `path` for
state that the server reads: with another path, only requests under that path carry the cookie, and the server sees the
default everywhere else.
A cookie is sent with every matching request, so a large value makes every request larger, and servers and proxies limit
the total size of the request headers. After each write, persistent-state reads `document.cookie` back. A cookie that
the browser refused or that exceeded its limit does not read back: the write is reported to `onError` as `write-failed`,
`isPersistent` becomes `false`, and the value stays in memory. A later successful write sets `isPersistent` back to
`true`.
Cookies written by older versions, duplicates and reset()
Reads accept a cookie under any name an older version may have written it with: the canonical name, the raw key, and its
single and double URI-encoded forms. The first name that is present wins. `reset()` expires the cookie under every one
of those names, with the definition's `path` and `domain`.
`reset()` therefore cannot reach a cookie of the same name that was set on a different path or on a different domain.
That cookie stays, and a later read can find it and bring an old value back.
Two cookies of one name can also coexist when they were set with different paths or domains. The browser lists both in
`document.cookie`, and persistent-state uses the first one on the client. A Next.js server uses the last one, because
its request cookies keep the last value of a repeated name. The server render and the client can then show different
values. Remove the stray cookie, for example by expiring it with its original `path` and `domain`.
## Web Storage
`localStorage` and `sessionStorage` hold about 5 MiB per origin, depending on the browser, and never leave the browser on
their own.
- When a write throws because the storage is full, the error is reported to `onError` as `write-failed`, `isPersistent`
becomes `false` and the value stays in memory. A full storage still lets stored values be read and removed.
- When the storage is unavailable, for example because the browser blocks it, persistent-state reports `unavailable`
once, keeps the value in memory for the page, and sets `isPersistent` to `false`. A storage failure is reported to
`onError` and never thrown.
- An `expires` that has passed is checked when the item is read, not by a timer. See
[Expiry](/guides/schemas-and-migrations/#expiry).
- Browsers may remove storage on their own. Safari, for example, deletes data created by script for an origin that the
visitor has not interacted with for seven days of browser use, when cross-site tracking prevention is on; cookies set
by the server are exempt. See the MDN page on
[storage quotas and eviction criteria](https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria).
Treat stored state as a preference you can recreate, not as the only copy of data.
## Synchronization
With `sync` on, which is the default, definitions follow changes made in other tabs:
- **`localStorage`**: `storage` events for the stored name run the read again, including the one a tab sends when it
calls `clear()`.
- **`cookie`**: after a successful cookie write or removal, the writing tab posts a message on a `BroadcastChannel`
named `persistent-state`. The message carries only the definition's identity, and the receiving tab reads
`document.cookie` itself. Cookie definitions also read again when a hidden tab becomes visible, which picks up cookies
that the server set in the meantime.
- **`sessionStorage`**: never synced.
- A value that lives only in memory, because storage is unavailable or `consent` is not given, is never synced.
Inside one tab, every hook and definition with the same storage and full key shares one record and updates together,
regardless of `sync`.
## Privacy
Stored values are not encrypted or signed. The visitor can read and change them, and so can any script that runs on the
page, in all three storages. Never store a secret or a session credential in persistent-state, and validate what you
read with a [schema](/guides/schemas-and-migrations/) before you trust it.
Whether a stored value needs the visitor's consent depends on its purpose and on the law that applies to you.
persistent-state does not decide that, and nothing in these docs states that a setup complies with a law. The `consent`
option stores a value only while you allow it: see [Consent](/guides/consent/).
# Consent
> Gate reading and writing of stored state behind a consent predicate, re-evaluate it with refreshConsent(), and wire it to a consent manager.
This page covers the `consent` option: what a definition does while consent is missing, when persistent-state evaluates
it, how to react when a visitor changes their choice, and how to connect it to a consent manager such as ConsentLayer.
persistent-state has no consent logic of its own. You give a definition a function that answers whether storing its value
is allowed right now, and persistent-state asks it at the moments listed below. Whether a stored value needs consent, and
what counts as valid consent, depends on its purpose and on the law that applies to you. Nothing on this page states that
a setup complies with a law; see the
[legal notice of ConsentLayer](https://consentlayer.nicolopadovan.com/guides/legal/) for how to approach that question.
## The consent predicate
The `consent` option takes a function that returns `true` when the value may be stored.
```ts title="lib/state.ts"
import { persistentState } from '@nicolopadovan/persistent-state'
export const theme = persistentState('theme', {
default: 'system',
storage: 'cookie',
consent: () => preferencesAllowed(),
})
```
A definition without `consent` always reads and writes. The function must return a boolean, and any falsy value
counts as no consent. A function that throws also counts as no consent, and the error is logged with `console.error`.
`preferencesAllowed()` stands for your own check.
While `consent` returns `false`:
| Operation | Behavior |
| --------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Reading | Storage is not read. `get()` returns the value in memory, which starts as the `default`. |
| Writing | Storage is not written. `set()` still updates the value in memory and notifies subscribers, without calling `onError`. |
| `isPersistent` | `false`. |
| Synchronization | The value is not synced between tabs. |
| `reset()` | Removes the stored item and sets the default. See [Withdraw consent](#withdraw-consent). |
The value that lives in memory is lost when the page closes. It is written only after consent is given, as described in
[Grant consent later](#grant-consent-later).
## When the predicate runs
persistent-state evaluates `consent` at these moments:
- The first time a definition is used in a page, before it reads storage.
- Before every write, including when a debounced write flushes, so a withdrawal that happens between `set()` and the
flush prevents the write.
- When a change from another tab is applied.
- In `refreshConsent()`.
It does not run on a timer, and it does not subscribe to your consent manager. When the visitor's choice changes, tell
persistent-state with `refreshConsent()`.
## Grant consent later
`refreshConsent()` re-evaluates `consent` for every definition that the page has already used and applies the change:
```ts
import { refreshConsent } from '@nicolopadovan/persistent-state'
refreshConsent()
```
| Consent was | Consent is now | Result |
| ----------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `false` | `true` | A value that was set in memory is written. Otherwise the stored value is read and subscribers are notified. Synchronization turns on. |
| `true` | `false` | The value stays in memory and `isPersistent` becomes `false`. The stored item is kept. Synchronization turns off. |
| Unchanged | Unchanged | Nothing happens. |
Definitions that the page has not used yet are not affected: they evaluate `consent` on first use.
## Withdraw consent
Withdrawing consent stops persistent-state from reading and writing, and it does nothing else. It never deletes a stored
item by itself. A consent manager that has not loaded yet can report `false` for a moment, and removing the value of
every returning visitor in that moment would be wrong. Removing the item is your decision, and `reset()` is how you make
it:
```ts
theme.reset()
```
`reset()` removes the stored item, including every older name that a cookie may have been written under, using the
definition's `path` and `domain`. It works while `consent` is `false`, sets the value in memory to the default, and
notifies subscribers.
`reset()` cannot reach a cookie of the same name that was set on a different path or on a different domain. If an older
version of your site, or another application, set such a cookie, it survives and a later read can find it. Expire those
cookies with the `path` and `domain` they were set with.
## The server does not evaluate the predicate
`readState()`, `PersistentStateProvider` and `readCookieState()` read cookies on the server, where there is no consent
manager to ask. They do not call `consent`. A cookie exists only if it was written while consent was given, so the server
reads it whenever it is present.
This matters for a visitor who withdrew consent and whose cookie was not removed with `reset()`: the server still
renders the stored value. After hydration, the client evaluates `consent`, finds that it is `false`, and shows the
default. Call `reset()` when a visitor withdraws, so that the server never sees the cookie again.
## Use it with ConsentLayer
[ConsentLayer](https://consentlayer.nicolopadovan.com) exposes the visitor's choice as `analytics.consent.getConsent()`,
which returns the consent state or `null`, and `analytics.consent.subscribe()`, which calls its listener with
`{ consent }` after every change. See [the consent events](https://consentlayer.nicolopadovan.com/guides/events/#consent).
Use the `preferences` category for UI state such as a theme.
```ts title="lib/state.ts"
import { persistentState, refreshConsent } from '@nicolopadovan/persistent-state'
import { analytics } from '@/lib/analytics'
export const theme = persistentState('theme', {
default: 'system',
storage: 'cookie',
consent: () => analytics.consent.getConsent()?.preferences === true,
})
analytics.consent.subscribe(({ consent }) => {
if (!consent?.preferences) theme.reset()
refreshConsent()
})
```
The listener resets the definition when `preferences` is not granted, and then re-evaluates every definition so that a
grant is applied and a withdrawal stops writes.
`getConsent()` returns the visitor's choice. Before a choice it returns the categories that the visitor's region grants by
default, or `null` when it grants none; see [the regions guide](https://consentlayer.nicolopadovan.com/guides/regions/).
The predicate follows whatever it returns, so the behavior in a region follows the defaults that you configure there.
Pick the category that matches the purpose of each value; this guide does not decide which purposes need consent.
ConsentLayer also notifies subscribers when it restores a stored choice while it bootstraps, so the listener runs again
once the choice of a returning visitor is known.
The same wiring for any consent manager
The predicate is a plain function, so any source of consent works: a cookie of your own, a global that your consent
banner sets, or a call to your consent platform. Do two things:
1. Return `true` from `consent` only when the value may be stored.
2. Call `refreshConsent()` whenever the choice changes, and call `reset()` on the definitions whose value you must remove
when it is withdrawn.
```ts
window.addEventListener('consent-changed', () => {
if (!preferencesAllowed()) theme.reset()
refreshConsent()
})
```
# Next.js App Router
> Render stored cookie state on the server with PersistentStateProvider and readState, without a flash of the default value.
This page covers using persistent-state in the Next.js App Router: how a cookie definition renders its stored value in
the first HTML, how to read it in Server Components, Server Actions and Route Handlers, what it means for dynamic
rendering, and what is not possible yet.
The `@nicolopadovan/persistent-state/next` entry point needs Next.js 15 or 16 and imports `next/headers`. Import it only
from Server Components, Server Actions and Route Handlers. A server can read only cookies, so only definitions with
`storage: 'cookie'` render their stored value on the server. See [Choosing a storage](/guides/choosing-a-storage/).
## Set up
### 1. Declare the state in a shared module
Declare each key once and import it from both server and client code, so do not add `'use client'` to this module. The
schema makes the server and the client accept only the values you expect.
```ts title="lib/state.ts"
import { persistentState } from '@nicolopadovan/persistent-state'
import { z } from 'zod'
export const theme = persistentState('theme', {
default: 'system',
storage: 'cookie',
schema: z.enum(['light', 'dark', 'system']),
})
export const currency = persistentState('currency', {
default: 'EUR',
storage: 'cookie',
schema: z.enum(['EUR', 'USD', 'GBP']),
})
```
Without a schema, give the type explicitly. TypeScript does not infer the remaining type arguments once you pass some,
so pass the storage too: `persistentState('theme', { default: 'system', storage: 'cookie' })`.
### 2. List the definition in the provider
`PersistentStateProvider` is an async Server Component. Render it in the layout that wraps every component that renders
the state, and list the definitions in `states`.
```tsx title="app/layout.tsx"
import type { ReactNode } from 'react'
import { PersistentStateProvider } from '@nicolopadovan/persistent-state/next'
import { theme } from '@/lib/state'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
{children}
)
}
```
The provider reads the cookies of the listed definitions, under every name an older version may have written them, and
passes their raw values to `CookieValuesProvider` from `@nicolopadovan/persistent-state/react`. No other cookie reaches
the client.
### 3. Use the state in Client Components
```tsx title="components/theme-toggle.tsx"
'use client'
import { useStoredState } from '@nicolopadovan/persistent-state/react'
import { theme } from '@/lib/state'
export function ThemeToggle() {
const [value, setValue] = useStoredState(theme)
return (
)
}
```
`useStoredState()` needs no provider for `localStorage` or `sessionStorage` definitions, and no provider for cookie
definitions on the client. The provider matters only for the server render and for hydration.
## Why there is no flash
During server rendering, `useStoredState()` gives React the value decoded from the provider's raw cookie. During
hydration on the client it gives React the same raw cookie, decoded the same way, so the first client render equals the
server's HTML. After hydration the value comes from `document.cookie`, which is normally the same value.
| Definition | Server render | After hydration |
| --------------------------------------------- | ----------------- | ---------------------------------------------------- |
| `cookie`, listed in `PersistentStateProvider` | The stored value. | The same value: no flash. |
| `cookie`, not listed | The default. | The stored value, after one render with the default. |
| `localStorage` or `sessionStorage` | The default. | The stored value, after one render with the default. |
List every cookie definition that the first paint depends on. In development, a cookie definition that is rendered on the
server under a provider that does not list it logs one warning naming its key.
## Read cookie state on the server
`readState()` reads a cookie definition in a Server Component, a Server Action or a Route Handler. It returns the
decoded value, migrated and validated as on the client, or the default when the visitor has no cookie for it.
```tsx title="app/products/page.tsx"
import { readState } from '@nicolopadovan/persistent-state/next'
import { currency } from '@/lib/state'
import { listProducts } from '@/lib/products'
export default async function ProductsPage() {
const selected = await readState(currency)
const products = await listProducts(selected)
return (
{products.map((product) => (
{product.name}: {product.price}
))}
)
}
```
Passing a definition whose `storage` is not `'cookie'` is a type error. `readState()` neither writes the cookie back nor
evaluates the definition's `consent` predicate. A migrated value is written back the next time the browser reads the
cookie. See [Consent](/guides/consent/#the-server-does-not-evaluate-the-predicate).
For a server that is not Next.js, `readCookieState(definition, cookies)` from the root entry point does the same with any
object that has a `get(name)` method returning `{ value }`, or with a `Cookie` header string.
## Dynamic rendering
Reading cookies makes a route dynamic. `PersistentStateProvider` and `readState()` both call `cookies()` from
`next/headers`, so every route that renders them is rendered for each request instead of being prerendered at build time.
- Render `PersistentStateProvider` in the layout that needs it, such as the layout of a route group, rather than the root
layout, so that marketing pages and other routes that do not render the state can stay static.
- State in `localStorage` and `sessionStorage` does not read cookies and costs nothing in rendering mode. Prefer it for
state the first paint does not depend on.
- With Cache Components, the Next.js documentation says that calling `cookies()` outside a `` boundary prevents
the route from being prerendered. See the Next.js documentation for
[`cookies()`](https://nextjs.org/docs/app/api-reference/functions/cookies).
What the 0.2.1 CookiesProvider cost
Version 0.2.1 depended on `next-client-cookies` and required its provider, re-exported as
`PersistentStateCookiesProvider`, around every use, including uses of `localStorage` and `sessionStorage`. The provider
belonged in the root layout, where reading cookies made every route dynamic whether or not it used persisted state.
Starting with 0.3.0, the dependency and the provider are gone. A provider is needed only to render cookie definitions on
the server, and you place it where it is needed. See [Migrating](/help/migrating/) to move from 0.2.1.
## Other servers and frameworks
In another React server-rendering framework, render `CookieValuesProvider` from
`@nicolopadovan/persistent-state/react` with the request's cookies, as a `Cookie` header string or as a record of cookie
names to values. Render it with the same cookies on the server and on the client.
```tsx
import type { ReactNode } from 'react'
import { CookieValuesProvider } from '@nicolopadovan/persistent-state/react'
export function Document({ request, children }: { request: Request; children: ReactNode }) {
return {children}
}
```
## Writing state from the server
Server-side writes are not supported in 0.3.0. On the server, `set()` and `reset()` throw an error that names
`readState()`, and a Server Action cannot set a persistent-state cookie. `writeState` and `resetState` for Server
Actions and Route Handlers are planned for 0.4.0; see the [roadmap](/help/roadmap/).
Until then, change the value in a Client Component with `set()`, and pass whatever a Server Action needs as an argument
instead of relying on the cookie having been written by then. `get()` returns the default on the server, and
`subscribe()` does nothing there.
## Troubleshooting
- **The value flashes from the default to the stored value.** The definition is not listed in `states`, it is stored in
`localStorage` or `sessionStorage`, or its cookie is not sent for this route because of its `path`. Keep the default
`path` of `'/'` for state that the server reads.
- **A warning names a key in development.** A cookie definition rendered on the server is missing from the `states` of the
provider above it.
- **The server and the browser disagree.** Two cookies of the same name exist, set with different paths or domains. The
server uses the last one and the browser uses the first. See
[Choosing a storage](/guides/choosing-a-storage/#cookies).
# Schemas and migrations
> Validate stored values with any Standard Schema library, upgrade older values with version and migrate, and limit how long a value is kept.
This page covers what persistent-state does with a value it reads: validating it with a schema, upgrading it from an
older shape with `version` and `migrate`, expiring it, and the text it stores.
Stored values are not trusted. A visitor, a script, an older release of your own code or a newer one can all leave
something in storage that your current types do not describe. A schema decides what is accepted, `version` and
`migrate` carry old values forward, and the default covers everything else.
## Validate with a schema
The `schema` option takes any library that implements [Standard Schema](https://standardschema.dev), such as zod,
valibot or ArkType. Every value read from storage is passed to the schema, and the type of the value is the schema's
output type.
```ts title="lib/settings.ts"
import { persistentState } from '@nicolopadovan/persistent-state'
import { z } from 'zod'
export const settings = persistentState('settings', {
default: { theme: 'system', pageSize: 20 },
schema: z.object({
theme: z.enum(['light', 'dark', 'system']),
pageSize: z.number().int().min(1).max(100),
}),
})
```
The same definition with valibot:
```ts title="lib/settings.ts"
import { persistentState } from '@nicolopadovan/persistent-state'
import * as v from 'valibot'
export const settings = persistentState('settings', {
default: { theme: 'system', pageSize: 20 },
schema: v.object({
theme: v.picklist(['light', 'dark', 'system']),
pageSize: v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)),
}),
})
```
The type `T` is inferred from the schema, and `default` and the result of `migrate` must match it, so a mismatch is a
type error.
### What happens when a value is invalid
A stored value that the schema rejects is not used. `get()` returns the `default`, the failure is reported to `onError`
as a `PersistentStateError` with the code `invalid` and the schema's `issues`, and the stored item is left as it is.
```ts
import { persistentState, type PersistentStateError } from '@nicolopadovan/persistent-state'
export const settings = persistentState('settings', {
default: { theme: 'system', pageSize: 20 },
onError: (error: PersistentStateError) => {
if (error.code === 'invalid') console.warn(error.key, error.issues)
},
})
```
Without `onError`, every failure is logged with `console.warn` and the prefix `[persistent-state]`.
- The schema runs when storage is read: on the first `get()` in a page, when another tab changes the value, after
`refreshConsent()` grants consent, and in `readState()` and `readCookieState()` on the server. It does not run on every
`get()`.
- The `default` is not validated, and nothing is validated when nothing is stored.
- The schema must validate synchronously. A schema that returns a promise is reported as `async-schema` and the default
is used.
- A schema that throws is reported as `invalid`, with the error as `cause`.
:::caution[A schema must accept its own output]
`set()` stores the value you pass as it is, which is a value of the schema's output type, and a value returned by
`migrate` is stored as the schema's output. The next read passes the stored value to the schema as input. A schema that
rejects its own output therefore reports `invalid` on the next read, and the value you set is replaced by the default.
:::
For example, this schema reads text and outputs a number. `set(5)` stores the number `5`, and the next read hands `5` to
a schema that expects text:
```ts
// Rejects its own output: set(5) is stored, then reported as invalid on the next read.
schema: z.string().transform((text) => Number(text))
// Accepts what it outputs: a number, or text that coerces to one.
schema: z.coerce.number()
```
Use a schema whose input accepts its output, or leave transformations to your own code after `get()`.
## Upgrade older values with version and migrate
Change the shape of a stored value by raising `version` and describing how to upgrade the old shape in `migrate`.
`version` and `migrate` must be set together: `persistentState()` throws a `TypeError` for one without the other.
A value stored without a version has version `0`, which includes every value that was stored before you added
`version`. The first `version` you set is therefore `1`, and `migrate` receives `0` for the old values.
```ts title="lib/profile.ts"
import { persistentState } from '@nicolopadovan/persistent-state'
import { z } from 'zod'
export const profile = persistentState('profile', {
default: { name: 'Guest', visits: 0 },
schema: z.object({ name: z.string(), visits: z.number() }),
version: 1,
migrate: (stored) => {
const { username } = z.object({ username: z.string() }).parse(stored)
return { name: username, visits: 0 }
},
})
```
`migrate(stored, fromVersion)` receives the stored value, typed `unknown`, and the version it was stored under. It runs
when the stored version is lower than `version`. Its result goes through the `schema`, and the validated value is written
back once, in the browser, so the migration runs once per stored item. The server's `readState()` migrates and validates
but never writes.
Parse the old shape inside `migrate`, as the example does: if the parse throws, the failure is reported as
`migrate-failed` instead of producing a wrong value.
Migrating across several versions
A stored value can be several versions behind. Step through each version it has missed, so every older shape reaches the
current one:
```ts
const Profile = z.object({ name: z.string(), visits: z.number(), locale: z.string() })
export const profile = persistentState('profile', {
default: { name: 'Guest', visits: 0, locale: 'en' },
schema: Profile,
version: 2,
migrate: (stored, fromVersion) => {
let value = stored
if (fromVersion < 1) {
const { username } = z.object({ username: z.string() }).parse(value)
value = { name: username, visits: 0 }
}
if (fromVersion < 2) {
value = { ...z.object({ name: z.string(), visits: z.number() }).parse(value), locale: 'en' }
}
return Profile.parse(value)
},
})
```
### When migration fails
A `migrate` that throws, or that returns `undefined`, is reported as `migrate-failed`. `get()` returns the default, and
the stored item is kept as it is, so the migration is tried again on the next page after you fix it. A migrated value
that the schema rejects is reported as `invalid` and kept the same way.
### When the stored version is newer than the code
A stored version above `version` was written by newer code, for example by a deployment you rolled back or by another
app on the same origin. It is reported as `version-ahead`, and the newer code's data is protected:
- `get()` returns the default.
- `set(prev => ...)` starts from the default, because `prev` is what `get()` returns.
- `set(value)` replaces the newer item with a value of the current version.
- `reset()` removes the newer item.
- Reads never change or remove it. The item stays through every page load until a `set()` or a `reset()`.
The check comes before the expiry, so a newer item is never removed, even when it is past its expiry. An item that
another format of the envelope wrote is treated the same way. A definition without a `version` has version `0`, so a
value stored with any `version` is ahead of it.
A `set()` of a value that equals the current one does nothing, so setting the default does not replace a newer item.
Call `reset()` to remove it.
### What each failure does to the stored item
| Code | Cause | Value returned | Stored item |
| ---------------- | --------------------------------------------------------------------------------- | -------------- | ---------------------------------------------------- |
| `invalid` | The schema rejected the value, threw, or returned no result. | The default. | Kept. `set()` replaces it and `reset()` removes it. |
| `async-schema` | The schema returned a promise. | The default. | Kept. |
| `migrate-failed` | `migrate` threw or returned `undefined`. | The default. | Kept. The migration is tried again on the next read. |
| `version-ahead` | The stored version is above `version`, or the envelope format is newer. | The default. | Kept, even when it is past its expiry. |
| `parse-failed` | The custom serializer's `deserialize` threw, or the stored envelope is malformed. | The default. | Kept. |
## Expiry
The `expires` option limits how long a value is kept. A number is a count of days, and a `Date` is a fixed point in time.
It means different things for each storage:
| `expires` | `cookie` | `localStorage` and `sessionStorage` |
| -------------------------------------------------- | --------------------------------------- | --------------------------------------- |
| Not set | 365 days from each write. | Never expires. |
| A number of days, such as `7` or `0.5` | That many days from each write. | That many days from each write. |
| `0` | A session cookie. | No expiry, the same as not setting it. |
| A `Date` | That date, as the cookie's `Expires`. | That date, fixed. |
| A negative or non-finite number, an invalid `Date` | A `TypeError` from `persistentState()`. | A `TypeError` from `persistentState()`. |
`expires: 0` is not "expire at once": for Web Storage it means no expiry, and for a cookie it means a session cookie,
which the browser discards when the session ends.
A number is counted from every write, so each `set()` pushes the expiry forward. A `Date` does not move.
```ts
export const banner = persistentState('promo-banner-dismissed', {
default: false,
expires: 30,
})
```
For a cookie, the browser enforces the expiry through the cookie's `Expires` attribute. For Web Storage, persistent-state
stores the expiry with the value and checks it when it reads the item: the read yields the default and removes the item
once the expiry has passed. A page that already holds the value in memory keeps it until the next read.
## The stored format
Without a `version` and without a Web Storage `expires`, the stored text is the serialized value alone, with no
envelope. Every value, strings included, goes through the serializer,
which is JSON by default, so the string `"42"` is stored as `"42"` with its quotes and reads back as a string.
With a `version`, or with a Web Storage `expires`, the stored text is an envelope, a JSON object that wraps the value:
```json
{ "__ps": 1, "v": { "name": "Ada", "visits": 3 }, "ver": 1, "exp": 1790000000000 }
```
| Field | Content |
| ------ | ---------------------------------------------------------------------------------------------------- |
| `__ps` | The envelope format, `1`. |
| `v` | The value. With a custom `serializer`, the text that `serialize()` returned. |
| `ver` | The version the value was written under. Omitted for version `0`. |
| `exp` | The expiry in epoch milliseconds, for Web Storage. Omitted otherwise, and never written for cookies. |
When a value is read, persistent-state parses the text and treats it as follows:
- An object with `__ps: 1` is an envelope. A malformed one is reported as `parse-failed`.
- An object whose `__ps` is anything else was written in a newer envelope format. It is reported as `version-ahead`
and is never written back.
- Anything else is a plain value with version `0` and no expiry, which includes everything written by version 0.2.1.
Plain strings written by 0.2.1
Version 0.2.1 stored strings without quotes. When the text is not valid JSON, or when `default` is a string and the
parsed value is not a string, the text itself is the value, so those strings still read correctly. A definition whose
default is not a string, such as `null` typed `string | null`, reads a raw `42` or `true` as that number or boolean, and a
schema that expects a string reports it as `invalid`. Give such a definition a string default, or have it save its value
again. See
[Migrating](/help/migrating/).
# Changelog
> Every notable change to persistent-state, release by release.
import Changelog from '../../../components/Changelog.astro'
# Comparison
> How persistent-state compares with use-local-storage-state, usehooks-ts, zustand persist, jotai atomWithStorage and Mantine hooks, with every fact dated 2026-10-15.
This page compares persistent-state with five libraries that keep React state in browser storage, and says when one of
them is the better choice. Every fact about another library was checked on **2026-10-15**, against the version in the
table below. Libraries change, so check a library's own documentation before you decide. "Not documented" means that no
option or section for the feature appears in the documentation and published package that were read, not that a
workaround is impossible. Anything that could not be confirmed is left out.
## Libraries compared
| Library | Version checked | Weekly downloads | What it is |
| ----------------------------------------------------------------------------------------- | --------------- | ------------------ | ----------------------------------------------------------------------- |
| persistent-state | 0.3.0 | not applicable | Hooks and definitions over a cookie, `localStorage` or `sessionStorage` |
| [use-local-storage-state](https://www.npmjs.com/package/use-local-storage-state/v/20.0.0) | 20.0.0 | about 895 thousand | A hook for state in `localStorage` |
| [usehooks-ts](https://www.npmjs.com/package/usehooks-ts/v/3.1.1) | 3.1.1 | about 6.2 million | A collection of hooks, with storage hooks among them |
| [zustand](https://www.npmjs.com/package/zustand/v/5.0.15) `persist` middleware | 5.0.15 | about 62.7 million | A store middleware that persists a store |
| [jotai](https://www.npmjs.com/package/jotai/v/3.0.1) `atomWithStorage` | 3.0.1 | about 6.7 million | An atom that persists its value |
| [Mantine hooks](https://www.npmjs.com/package/@mantine/hooks/v/9.7.2) | 9.7.2 | about 3.0 million | The hooks package of the Mantine component library |
Weekly downloads are the npm registry's count for the week ending 2026-10-12 and say how widely a library is used, not
how well it fits. The zustand and jotai counts are for the whole library, not for persistence.
## Storage and server reads
| Library | Storages | Cookies | Server-side reads |
| ----------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| persistent-state | `cookie`, `localStorage` and `sessionStorage` under one API | Yes, with `path`, `domain`, `secure`, `sameSite` and `expires` | Cookies only: `readState()`, `readCookieState()` and `PersistentStateProvider` render the stored value on the server |
| use-local-storage-state | `localStorage`. The README points to a separate package for `sessionStorage` | No | A `defaultServerValue` option sets the value used for the server render |
| usehooks-ts | `useLocalStorage` and `useSessionStorage`, and `useReadLocalStorage` for reading | No cookie hook is exported | `initializeWithValue: false` renders the initial value first; the documentation recommends it for server rendering |
| zustand `persist` | `localStorage` by default, or any storage you pass to `createJSONStorage`, including async | Not built in | Hydration reads storage on the client. The documentation describes waiting until after hydration in Next.js, and `skipHydration` to rehydrate later |
| jotai `atomWithStorage` | `localStorage` by default, or `sessionStorage`, AsyncStorage or a custom storage | Not built in | The server renders `initialValue`; `getOnInit` defaults to `false`, and the documentation suggests rendering stored values on the client only |
| Mantine hooks | `useLocalStorage` and `useSessionStorage` | No cookie hook is exported | `getInitialValueInEffect` defaults to `true`, so storage is read after mount |
## Sync across tabs
| Library | Cross-tab sync |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| persistent-state | On by default (`sync`). `localStorage` follows `storage` events and cookies follow a `BroadcastChannel` message. `sessionStorage` is per tab, never synced |
| use-local-storage-state | On by default (`storageSync`): `storage` events across tabs, windows and iframes, and hooks in the same tab |
| usehooks-ts | `useLocalStorage` listens to the `storage` event and to a custom event for hooks in the same tab |
| zustand `persist` | Not built in. The FAQ shows listening for the `storage` event and calling `rehydrate()` yourself |
| jotai `atomWithStorage` | The default storage subscribes to `storage` events |
| Mantine hooks | `sync` defaults to `true`; the documentation says the hook subscribes to the `storage` event and updates every open tab |
## Validation, migrations and expiry
| Library | Validation | Migrations | Expiry |
| ----------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------- |
| persistent-state | Any Standard Schema (zod, valibot, arktype); an invalid stored value yields `default` | `version` and `migrate` | `expires`: a cookie's expiry in days, or a stored deadline for Web Storage |
| use-local-storage-state | Not documented | Not documented | Not documented |
| usehooks-ts | No option | No option | No option |
| zustand `persist` | No option; `merge` controls how stored state combines with current state | `version` and `migrate`, which may be async | No option |
| jotai `atomWithStorage` | Through a custom storage, or the exported `unstable_withStorageValidator` | Not documented | Not documented |
| Mantine hooks | No option | No option | No option |
## Consent, write timing and dependencies
persistent-state accepts a `consent` predicate. While it returns `false`, storage is neither read nor written. It also
accepts `debounceMs`, which delays storage writes and flushes pending ones on `pagehide` and when the page becomes
hidden.
| Library | Dependencies and requirements |
| ----------------------- | ---------------------------------------------------------------------------------------- |
| persistent-state | No runtime dependencies. Peers: `react` 18.3 or 19, and `next` 15 or 16 for `/next` only |
| use-local-storage-state | No dependencies. Peers: `react` and `react-dom` 18 or later |
| usehooks-ts | Depends on `lodash.debounce` |
| zustand `persist` | Part of zustand, so it needs a zustand store |
| jotai `atomWithStorage` | Part of jotai, so it needs a jotai store |
| Mantine hooks | Peer `react` 19.2 or later |
## What no checked library combines
Across the documentation and options checked on 2026-10-15, none of the five libraries documents a single React API
over cookies and Web Storage with server-side reads, and none documents consent gating or debounced storage writes
flushed on page hide. persistent-state is built around that combination. This is an absence in the documentation that
was read, so treat it as a statement about 2026-10-15 and not as a guarantee.
## When to choose another library
persistent-state is not the smallest or the most widely used option. Choose another library when:
- **You need only `localStorage`, in components that render on the client.** use-local-storage-state does that with no
dependencies and a small surface. It covers cross-tab sync, an in-memory fallback with `isPersistent`, and
`defaultServerValue`. persistent-state returns the same `[value, setValue, meta]` shape, so moving between them
means translating options, and `removeItem` becomes `reset`.
- **You already manage state with zustand or jotai.** Their persistence belongs to the store, with `partialize`, `merge`
and the store's own subscriptions. persistent-state does not replace a state manager. The roadmap lists
[interop with both](/help/roadmap/) for 1.1.0, which is not released.
- **You already use Mantine or usehooks-ts and need no cookies, server reads or validation.** The storage hooks ship with
the library, and adding another dependency buys nothing.
- **You need asynchronous storage today.** zustand `persist` and jotai `atomWithStorage` document async storages such as
React Native's AsyncStorage. persistent-state's storage is synchronous until 0.9.0.
- **You do not use React.** persistent-state's core has no React dependency, but the bindings for Vue, Svelte and Solid
arrive in 0.6.0 to 0.8.0. See the [roadmap](/help/roadmap/).
- **You run React older than 18.3 or Node.js older than 22.** persistent-state does not support them.
Choose persistent-state when state that the first paint depends on, such as a theme, should render correctly on the
server from a cookie; when stored values need a schema, a version and a migration; when storage must wait for consent;
or when one API should cover cookies, `localStorage` and `sessionStorage`.
How the facts were gathered
On 2026-10-15, the version of each library named above was read from the npm registry: the package metadata for
dependencies and peer dependencies, the published files for option names and defaults, and the library's README or
documentation page for behavior. Download counts come from the npm downloads API for the week ending 2026-10-12. The
documented behaviors listed here are not the result of running the libraries.
# Migrating
> Upgrade to persistent-state 0.3.0 from 0.2.x or from a vendored copy of the source, with the code to change and what happens to the data already stored.
:::note
This page is for projects that already use persistent-state 0.2.x, published on GitHub Packages, or that vendored a copy
of its source as `@workspace/persistent-state`. If you are starting with persistent-state, skip it and follow
[Getting started](/start/getting-started/) instead.
:::
persistent-state 0.3.0 is the first release on the public npm registry. It replaces the 0.2.1 hooks with a definition
object created by `persistentState()`, moves `usePersistentState` to its own entry point, removes the dependency on
`next-client-cookies`, and changes a few defaults. The data your visitors already stored keeps working: **stored data
needs no migration**. This guide lists what to change in your code, then what to know about data that 0.2.1 wrote.
## Summary of changes
| 0.2.1 | 0.3.0 |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Installed from GitHub Packages with an `.npmrc` scope mapping and a read token | Installed from npm. [Remove the scope line and the token](#install-from-npm) |
| `import { usePersistentState } from '@nicolopadovan/persistent-state'` | [Import it from `/react`](#move-the-imports) |
| `` required around every use | [Remove it](#remove-the-provider-and-next-client-cookies). Cookie state rendered on the server uses `PersistentStateProvider` from `/next` |
| `next-client-cookies` peer dependency | [Remove it](#remove-the-provider-and-next-client-cookies) if nothing else uses it |
| `useStorage(type)` | [`persistentState()`](#replace-usestorage) and its `get`, `set` and `reset` |
| `getPersistentCookieItem(await cookies(), key)` | [`readState(def)`](#read-cookies-on-the-server); the function stays, deprecated, until 1.0.0 |
| `syncAcrossTabs` default `false` | `sync` default `true`; pass `sync: false` for the [old behavior](#renamed-options-and-new-defaults) |
| Cookie without `expires` is a session cookie | Cookie `expires` defaults to 365 days; `expires: 0` writes a session cookie |
| Two-element tuple | Three elements; the third is `{ reset, isPersistent }` |
| Strings stored raw | Strings stored as JSON; [raw strings written by 0.2.1 still read correctly](#strings-that-looked-like-numbers) |
## Requirements
| Requirement | 0.2.1 | 0.3.0 |
| ----------- | --------------------- | ---------------------------------------------------------------- |
| Node.js | 20 or later | 22 or later |
| React | 18 or later | 18.3 or later, or 19 |
| Next.js | not a peer dependency | 15 or 16, only for the `/next` entry point; the peer is optional |
| Packaging | ESM, one entry point | ESM only, with the entry points `.`, `/react` and `/next` |
## Install from npm
Remove the GitHub Packages registry mapping and the read token from `.npmrc`. If you install other `@nicolopadovan`
packages from GitHub Packages, keep the scope line.
```diff title=".npmrc"
-@nicolopadovan:registry=https://npm.pkg.github.com
-//npm.pkg.github.com/:_authToken=${GH_PACKAGES_READ_TOKEN}
```
Remove the `GH_PACKAGES_READ_TOKEN` secret from CI and the export from your shell profile if nothing else uses them.
Then update the dependency, and remove `next-client-cookies` in the same step:
```bash
npm install @nicolopadovan/persistent-state@^0.3.0
npm uninstall next-client-cookies
```
pnpm, yarn and bun use `add` and `remove` for the same two steps.
Every 0.x minor release may contain breaking changes, so pin the minor version, as the caret range above does for 0.x.
### Vendored copies
If your project imports persistent-state as `@workspace/persistent-state`, a workspace package that holds a copy of the
source, replace that package with the published one. Change the import specifier to
`@nicolopadovan/persistent-state`, move `usePersistentState` to `/react` as described below, delete the copy, and then
follow the rest of this page. Copies can diverge from each other and from 0.2.1, so check what yours exports against
[the table of removed exports](#move-the-imports). persistent-state 0.3.0 reads cookies under the raw, URI-encoded and
double URI-encoded form of a name, so a cookie written under any of those forms is still found.
## Move the imports
`usePersistentState` and its options type move from the root entry point to `/react`, so the core has no React
dependency.
```diff
-import { usePersistentState } from '@nicolopadovan/persistent-state'
-import type { UsePersistentStateOptions } from '@nicolopadovan/persistent-state'
+import { usePersistentState } from '@nicolopadovan/persistent-state/react'
+import type { UsePersistentStateOptions } from '@nicolopadovan/persistent-state/react'
```
These exports are removed in 0.3.0:
| Removed | Use instead |
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `PersistentStateCookiesProvider` | Nothing, or [`PersistentStateProvider`](#remove-the-provider-and-next-client-cookies) |
| `useStorage`, `StorageAPI`, `StorageOptions`, `CookieStorageOptions`, `NonCookieStorageOptions` | [`persistentState()`](#replace-usestorage) and the definition object it returns |
## Remove the provider and next-client-cookies
0.2.1 read cookies in the browser through `next-client-cookies`, which needed `PersistentStateCookiesProvider` around
every component that used a hook. 0.3.0 reads and writes `document.cookie` itself. Delete the provider:
```diff title="app/layout.tsx"
-import { PersistentStateCookiesProvider } from '@nicolopadovan/persistent-state'
-
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
- {children}
-
+ {children}
)
}
```
A provider is now needed only to render cookie state on the server. Declare the key with `persistentState()` in a module
that both server and client code can import, and list it in `PersistentStateProvider` from `/next`:
```ts title="lib/theme.ts"
import { persistentState } from '@nicolopadovan/persistent-state'
export const theme = persistentState('theme', { default: 'light', storage: 'cookie' })
```
```tsx title="app/layout.tsx"
import { PersistentStateProvider } from '@nicolopadovan/persistent-state/next'
import { theme } from '../lib/theme'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
```
`PersistentStateProvider` reads only the cookies of the definitions you list, so other cookies never reach the client.
Components read the value with `useStoredState(theme)` from `/react`. `usePersistentState` builds its definition
internally, so it cannot be listed in `states`: a cookie read through it renders `default` on the server and the stored
value after hydration. See [Next.js App Router](/guides/nextjs/) for the rendering rules.
## Replace `useStorage`
`useStorage(type)` returned an object with `getItem`, `setItem` and `removeItem` that took the key on every call. 0.3.0
declares each key once, with its default and options, and the definition carries the methods:
```tsx
// 0.2.1
const storage = useStorage('cookie')
const current = storage.getItem('theme', 'light')
storage.setItem('theme', 'dark')
storage.removeItem('theme')
```
```tsx
// 0.3.0
import { persistentState } from '@nicolopadovan/persistent-state'
import { useStoredState } from '@nicolopadovan/persistent-state/react'
const theme = persistentState('theme', { default: 'light', storage: 'cookie' })
theme.get()
theme.set('dark')
theme.reset()
// In a component, subscribe to it:
const [value, setValue, { reset, isPersistent }] = useStoredState(theme)
```
## Read cookies on the server
`getPersistentCookieItem(await cookies(), key)` becomes `readState(def)`:
```diff title="app/page.tsx"
-import { cookies } from 'next/headers'
-import { getPersistentCookieItem } from '@nicolopadovan/persistent-state'
+import { readState } from '@nicolopadovan/persistent-state/next'
+import { theme } from '../lib/theme'
-const current = getPersistentCookieItem(await cookies(), 'theme') ?? 'light'
+const current = await readState(theme)
```
`readState` returns the definition's `default` when no value is stored, where `getPersistentCookieItem` returned
`undefined`. For a server that is not Next.js, `readCookieState(def, cookies)` from the root entry point takes any object
with a `get(name)` method, or a `Cookie` header string.
`getPersistentCookieItem`, `getPersistentCookieValue`, `PersistentCookieStore` and `StorageType` still work in 0.3.0
with their 0.2.1 behavior, and are deprecated until 1.0.0. `PersistentCookieStore` is an alias of `CookieSource`, and
`StorageType` of `StorageKind`.
## Renamed options and new defaults
| 0.2.1 | 0.3.0 |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `storageType` | `storage` |
| `syncAcrossTabs`, default `false`, `localStorage` only | `sync`, default `true`. `localStorage` follows `storage` events, cookies follow a `BroadcastChannel` message. `sessionStorage` never syncs |
| `expires` unset: a session cookie | `expires` defaults to 365 days, recomputed on every write. `expires: 0` writes a session cookie |
| `secure` and `sameSite` sent only when set | `secure` defaults to `true` on https, and `sameSite` to `'lax'` |
| `onError` optional, errors ignored without it | `onError` receives a `PersistentStateError`. Without it, errors go to `console.warn` with a `[persistent-state]` prefix |
| `setValue(undefined)` removed the item | `set(undefined)` calls `reset()`: the item is removed and the value becomes `default` |
| `[value, setValue]` | `[value, setValue, { reset, isPersistent }]` |
The options `namespace`, `debounceMs`, `serializer`, `path` and `domain` keep their names. To keep the 0.2.1 sync, lifetime and
error behavior, set them explicitly:
```ts
const sidebar = persistentState('sidebar', {
default: false,
storage: 'cookie',
sync: false,
expires: 0,
onError: () => {},
})
```
### Deprecated aliases
`usePersistentState` still accepts `storageType` and `syncAcrossTabs`, as aliases of `storage` and `sync`. If you pass
both an alias and its new name, the new name wins. The aliases are deprecated and removed in 1.0.0, so rename them now:
```diff
-const [open, setOpen] = usePersistentState('sidebar', false, { storageType: 'cookie', syncAcrossTabs: true })
+const [open, setOpen] = usePersistentState('sidebar', false, { storage: 'cookie', sync: true })
```
`persistentState()` does not accept the aliases.
## Stored data
Values written by 0.2.1 are read by 0.3.0 without a conversion step, and values written by 0.3.0 without `version` or a
Web Storage `expires` stay readable by 0.2.1. Four details differ, and each can change what a visitor sees after the
upgrade.
### Strings that looked like numbers
0.2.1 stored a string as it was and every other value as JSON, then tried to parse whatever it read as JSON. A string such as
`"42"` or `"true"` was written as the text `42` or `true`, and read back as the number `42` or the boolean `true`.
0.3.0 writes every value, strings included, as JSON, so `"42"` is stored with its quotes and reads back as a string. To
read what 0.2.1 wrote, it applies one rule on reading: when the `default` of the definition is a string, and the stored
text parses as JSON to something that is not a string, the stored text itself is the value. A raw `42` written by 0.2.1
therefore reads as the string `"42"` for a state whose `default` is a string.
How the rule reads a stored value
Without a custom `serializer`, 0.3.0 runs `JSON.parse` on the stored text. If parsing fails, the stored text is the
value; for cookies, it first tries up to two more `decodeURIComponent` passes. If parsing succeeds and the `default` is a
string but the result is not, the stored text is the value. With a custom `serializer`, the rule does not apply and
`deserialize(text)` decides.
### Null and nullable strings
A state that holds `null` stored the text `null` in 0.2.1. When 0.2.1 read it back, it treated `null` as nothing stored
and returned the initial value, so a `null` never survived a reload.
0.3.0 reads that text as `null` when the `default` is not a string, whatever the other types of the state are. A
`default` of `null`, a number, a boolean or an object all read it back as `null`. If a visitor's saved `null` used to
come back as the default, it now stays `null`.
### The nullable-string gap
The rule for raw strings keys on `typeof default`. A state typed `string | null` whose `default` is `null` is not a
string state, so a raw value that 0.2.1 wrote as `42` or `true` reads as the number `42` or the boolean `true`. With a
`schema` that expects a string, the value is reported as `invalid` and the state yields `default`.
This affects only values that 0.2.1 wrote and that have not been written again. Any `set()` stores the value as JSON,
after which it reads back as a string. To close the gap for such a state:
- Give the state a string `default`, such as `''`, and treat the empty string as "no value", or
- Without a `schema`, write the value back as a string once, for example `set(String(value))`, which stores it as JSON.
A state whose `default` is a string reads a stored `null` as the text `"null"`, because the parsed `null` is not a
string. `set(null)` on such a state therefore round-trips as `"null"`. Clear the value with `reset()` instead, or choose
a `default` that is not a string and keep `null` for "no value".
### Cookie expiry and rollouts
Cookies that 0.2.1 wrote without `expires` are session cookies. They keep that lifetime until the value is written
again, and every write after the upgrade sets a 365-day expiry, unless the definition sets `expires: 0`.
Setting `version`, or an `expires` on a Web Storage definition, stores the value in a JSON envelope that looks like
`{"__ps":1,"v":…}`. 0.2.1 cannot read it as the plain value. If old and new code run at the same time during a rollout,
adopt `version` and Web Storage `expires` after every instance runs 0.3.0.
## `reset()` and legacy cookies
`reset()` removes a cookie by writing an expired cookie with the definition's own `path`, `domain`, `secure` and
`sameSite`, under every name variant the definition can read. A browser identifies a cookie by its name, domain and
path. A legacy cookie that 0.2.1 set on a different `path` or `domain` than the definition's has a different identity,
so `reset()` does not remove it, and the value can come back on the next read.
The default `path` is `/`. If your 0.2.1 code set a custom `path` or `domain`, declare the same values on the
definition, and `reset()` removes that cookie:
```ts
const theme = persistentState('theme', { default: 'light', storage: 'cookie', path: '/app' })
```
The same mismatch affects writes. A browser lists the cookies of a name with the longest path first, and a definition
reads the first cookie of a name. A legacy cookie on a longer path than the definition's therefore shadows every value
the definition writes: `set()` reports `write-failed`, `isPersistent` becomes `false`, and a later page load reads the
legacy value. Declaring the matching `path` fixes this too. Web Storage is not affected, because `reset()` removes the
item by its exact key.
## Cookie name variants that alias
When 0.3.0 reads a cookie, it tries the names that 0.2.1 and its copies could have written, in this order: the
canonical name, the raw key, the URI-encoded key and the double URI-encoded key. The first name that has a cookie wins.
`reset()` removes every one of them.
Two different keys can therefore share a name. The key `a+b` reads `a+b`, `a%2Bb` and `a%252Bb`. The key `a%2Bb` reads
`a%252Bb`, `a%2Bb` and `a%25252Bb`. The two keys overlap on `a%2Bb` and `a%252Bb`: each can read a cookie that the other
wrote, when its own is absent, and `reset()` of one removes the cookie of the other. The same holds for `ns:key` and
`ns%3Akey`. Because `namespace` joins with a colon, a `namespace` of `ns` and a key of `key` also stores as
`ns%3Akey`.
Avoid `%` and characters that `encodeURIComponent` escapes in cookie keys, or keep every key distinct once encoded. Web
Storage uses the exact key and has no variants.
## Checklist
- Remove the GitHub Packages scope line and token, and install `@nicolopadovan/persistent-state@^0.3.0` from npm.
- Move `usePersistentState` and `UsePersistentStateOptions` to `@nicolopadovan/persistent-state/react`.
- Delete `PersistentStateCookiesProvider` and remove `next-client-cookies`.
- Replace `useStorage` with `persistentState()`.
- Replace `getPersistentCookieItem` with `readState`, and list cookie definitions in `PersistentStateProvider`.
- Rename `storageType` to `storage` and `syncAcrossTabs` to `sync`, and decide on `sync`, `expires` and `onError`.
- Check states typed `string | null`, and cookies that were set on a custom `path` or `domain`.
# Roadmap
> What persistent-state plans to add before and after 1.0.0, in the order it ships, and the known gaps in the current release.
This page describes where persistent-state is heading and where the current release stops. It lists the planned
versions in the order they ship, and the known gaps in 0.3.0, each with a workaround where one exists. Planned work has
no dates; each version's [milestone on GitHub](https://github.com/nicolopadovan/persistent-state/milestones) lists its
issues, and the [changelog](/help/changelog/) records what each release actually ships.
## Planned
1.0.0 follows the roadmap core, which is 0.4.0 to 0.11.0. Each row is its own version and its own GitHub milestone. The
version links to the issues of that milestone.
| Version | Item | Priority |
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| [0.4.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%220.4.0%22) | Server writes: `writeState` and `resetState` in `/next` for Server Actions and Route Handlers; core `serializeStateCookie` for other servers | High |
| [0.5.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%220.5.0%22) | Static rendering with cookie state: a documented static mode without the provider, with a `hydrated` flag to hide values until they are read; `PersistentStateProvider` inside `` with Cache Components, tested and documented; ``, an inline `` script that sets attributes or CSS variables from cookies before the first paint, with a CSP nonce; and a middleware helper that rewrites to a prerendered variant per cookie value | High |
| [0.6.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%220.6.0%22) | Vue binding (`/vue` composable) and Nuxt server reads | High |
| [0.7.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%220.7.0%22) | Svelte binding (`/svelte`) and SvelteKit server reads | Medium |
| [0.8.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%220.8.0%22) | Solid binding (`/solid`) and SolidStart server reads | Medium |
| [0.9.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%220.9.0%22) | Async storage: a storage driver interface, an IndexedDB driver, and the loading state hooks need for it (also covers React Native storage) | High |
| [0.10.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%220.10.0%22) | URL-state interop: seed a definition from a search parameter and mirror it back, for shareable links | Medium |
| [0.11.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%220.11.0%22) | Devtools: an `inspect()` API listing every definition, its value, storage and persistence, and a small in-page panel | Low |
| [1.0.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%221.0.0%22) | API freeze: deprecated aliases removed, semver guarantees from here on | High |
| [1.1.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%221.1.0%22) | Interop: a zustand `persist` storage and a jotai `atomWithStorage` storage backed by definitions | Low |
| [1.2.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%221.2.0%22) | Server-synced storage: a driver that syncs through an endpoint you provide, across devices, with an offline queue | Medium |
| [1.3.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%221.3.0%22) | A hosted sync backend (under consideration; depends on 1.2.0 being used) | Low |
## Known gaps in 0.3.0
Each row describes behavior of the current release that you might not expect, with what you can do about it today.
| Gap | Workaround | Planned |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `set()` and `reset()` throw on the server, so a Server Action or Route Handler cannot change cookie state. | Change the value in the browser, for example in an event handler. Read cookie state on the server with `readState()` or `readCookieState()`. | [0.4.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%220.4.0%22) |
| Rendering cookie state in the first HTML with `PersistentStateProvider` or `readState()` makes the route dynamic, because both call `cookies()`. | Leave the provider out where a short switch after hydration is acceptable; the page stays static. Render the provider in the layout that needs it, not the root layout. With Cache Components, render it inside ``. | [0.5.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%220.5.0%22) |
| The hooks exist for React only: `/react` and `/next`. | The core imports nothing from React. A definition from `persistentState()` has `get()`, `set()`, `reset()` and `subscribe()`, which any framework can bind to, and `readCookieState()` reads cookies from any server runtime. | 0.6.0 to 0.8.0 |
| Storage is synchronous: a cookie, `localStorage` or `sessionStorage`. A `schema` must validate synchronously; a schema that returns a promise reports `async-schema` and yields `default`. | Use the storage of another library for asynchronous storage, such as React Native's. The [comparison](/help/comparison/) lists what each one offers. | [0.9.0](https://github.com/nicolopadovan/persistent-state/issues?q=milestone%3A%220.9.0%22) |
| A state whose `default` is a string reads a stored `null` as the text `"null"`. | Clear the value with `reset()` instead of `set(null)`, or give the state a `default` that is not a string. See [stored `null` and nullable strings](/help/migrating/#null-and-nullable-strings). | none |
| Cookie keys that differ only in how a character is encoded can read and remove each other's cookie. | Avoid `%` and characters that `encodeURIComponent` escapes in cookie keys. See [name variants that alias](/help/migrating/#cookie-name-variants-that-alias). | none |
# Cross-tab sync
> How a change in one tab reaches the others, through storage events and a BroadcastChannel, and when a tab does not follow.
This page explains how a value changed in one tab reaches the other tabs of the same browser: the transport each storage
uses, what a message carries and the conditions under which a tab re-reads. It is for readers who need the exact
behavior, for example to understand why a `sessionStorage` value never syncs, or what `sync: false` stops.
The listeners, the channel and the re-read conditions are in `src/core/state.ts` (`onStorage`, `onVisible`, `channel` and
`reread`).
## In one tab
Every definition with the same storage and key shares one [record](/internals/shared-record/), so a `set()` through any
of them notifies the subscribers of all of them, synchronously, in the same tab. No transport is involved.
## Between tabs
A tab learns about a change made elsewhere in one of three ways.
| Storage | Transport | Trigger |
| ---------------- | --------------------------------------------------------- | ------------------------------------------- |
| `localStorage` | The browser's `storage` event on `window`. | Another tab calls `setItem`, `removeItem`. |
| `cookie` | A `BroadcastChannel` named `persistent-state`. | Another tab writes or removes the cookie. |
| `cookie` | The `visibilitychange` event on `document`, to `visible`. | The tab becomes visible again. |
| `sessionStorage` | None. | A `sessionStorage` item belongs to one tab. |
The writing tab does not receive its own `storage` event or channel message; it already holds the new value.
### The `storage` event
The `storage` listener ignores events for any storage other than `localStorage`. An event with a `key` finds the record
with the id `localStorage:`. An event with a `null` key, which the browser fires when another tab calls
`localStorage.clear()`, finds every `localStorage` record. The record's [owner](/internals/shared-record/#several-definitions-of-one-key)
then re-reads it.
### The `BroadcastChannel`
Not every supported browser fires an event when another tab changes a cookie, so persistent-state announces its own
cookie writes. After a cookie is written successfully, and after one is removed, the tab posts the record's id, for
example `cookie:theme`, on the channel. The message carries no value and no cookie attributes: a receiving tab finds the
record with that id and re-reads `document.cookie` itself, through the same [read pipeline](/internals/read-pipeline/) as
any other read. It applies its own `consent`, `schema` and `migrate`, so a tab never takes a value it would not accept
from storage.
The channel is opened when the first cookie record of a page is created. A browser without `BroadcastChannel`, or one that
throws when opening it, leaves the channel unavailable for the page. Cookie sync then relies on the `visibilitychange`
event alone.
Messages are posted whether or not the posting definition has `sync: false`.
### Becoming visible
When a tab becomes `visible`, every cookie record in it is re-read. This catches changes made while the tab was in the
background, for example a cookie set by a `Set-Cookie` header on a response, and covers browsers without
`BroadcastChannel`. It applies only to cookies: Web Storage changes are always delivered by the `storage`
event.
## When a tab re-reads
A message, a `storage` event or the tab becoming visible finds a record, and the owner re-reads it only when all of these
hold:
- the owner's `sync` is not `false`, and its storage is not `sessionStorage`;
- the record has been loaded, because a record nobody has read yet has nothing to refresh;
- the record is persistent, so a value that lives only in memory, because storage is unavailable or consent is withheld,
is never replaced by what other tabs wrote;
- no write is pending, so a value waiting for its debounce is not overwritten, see
[Debounce and flush](/internals/write-pipeline/#debounce-and-flush);
- `consent` returns a truthy value, or is not set.
The re-read runs the whole read pipeline. The record's subscribers are notified only when the value, or whether it is
persistent, changed. A failure, such as a value another tab wrote that fails the `schema`, is reported to `onError` and the
record takes the default, as for any read.
## What `sync: false` does
`sync: false` stops one definition's tab from receiving updates: `storage` events and channel messages find the record,
and the re-read conditions fail. It does not change what the tab writes. A tab with `sync: false` still writes to storage,
still fires `storage` events in other tabs and still posts channel messages, so other tabs with `sync` left on follow it.
`sessionStorage` is never synced, whatever `sync` says.
Why the message carries only an id
A value in a message would be a second copy of the data, one that bypasses the receiving tab's `consent` check, its
`schema` and its `migrate`. An id keeps storage the single source of truth, and every tab validates what it reads in the
same way.
# Hydration snapshots
> What a component renders on the server and during hydration, why it never mismatches, and how cookie values reach the first render.
This page explains what `useStoredState()` and `usePersistentState()` render on the server and while a page hydrates,
how the value of a cookie reaches that first render, and why the stored value follows right after. It is for readers who
need the exact behavior, for example to understand why a Web Storage value appears one render late, or why the
development server logs a warning about a cookie definition.
The hooks and `CookieValuesProvider` are in `src/react/index.ts`; `PersistentStateProvider` and `readState()` are in
`src/next.tsx`.
## The two snapshots
Both hooks read a definition through React's `useSyncExternalStore`, which takes two functions that return the current
value:
- `getSnapshot` runs in the browser after hydration, and for the whole life of a client-only app. It returns
`definition.get()`, the value of the [shared record](/internals/shared-record/).
- `getServerSnapshot` runs while rendering on the server and while hydrating on the client. React requires that it
returns the same value on the server and on the client, so the first client render matches the server's HTML.
A client-only app, which never hydrates, never calls `getServerSnapshot`: its first render reads storage directly.
## What the server snapshot returns
| Definition | Server render and hydration | After hydration |
| ---------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------- |
| `localStorage` or `sessionStorage` | The `default`. | The stored value, in one more render if it differs. |
| `cookie`, below a provider | The provider's cookie value, or the `default` when it holds none. | The same value. |
| `cookie`, with no provider | The `default`. | The stored value, in one more render if it differs. |
The server never reads Web Storage, because it does not exist there, and the client never reads it while hydrating, so
the two renders cannot differ. A Web Storage value therefore appears in the render after hydration, the one React
schedules when `getSnapshot` returns something other than the server snapshot. A definition used for something that must
not flash, such as a theme, belongs in a cookie, with a provider.
The `isPersistent` flag returned by the hooks has its own server snapshot, which is always `true`. A component that shows
a notice when a value is not persistent therefore never renders it on the server, and the notice appears after hydration
only if the value really is not stored.
## How a cookie reaches the first render
`CookieValuesProvider` takes the request's cookies, either as a record of cookie names to values or as a raw `Cookie`
header, and parses them once into a jar. For a cookie definition, the server snapshot is then
[`readCookieState()`](/internals/read-pipeline/#server-reads) over that jar: the same decoding, migration and
validation as in the browser, and the default when the cookie is missing or unreadable. Failures go to the definition's
`onError`.
The snapshot is computed once per definition and provider, and cached in a `WeakMap` owned by the provider. React calls
`getServerSnapshot` repeatedly and compares the results with `Object.is`, so a schema that returns a new object each
time would otherwise make every render look like a change.
Render the provider with the same `cookies` on the server and on the client. In Next.js, `PersistentStateProvider` does
this: it runs on the server, reads `cookies()`, and passes the values to the client as the props of
`CookieValuesProvider`, so the browser hydrates from the same jar the server rendered from.
Which cookies reach the client
`PersistentStateProvider` does not pass the whole cookie jar to the client. For each definition in `states`, it reads the
cookie under every name an older version may have written (the names listed in
[the read pipeline](/internals/read-pipeline/#cookie-names)), and passes only those. Other cookies, such as a session
cookie, never leave the server, because the props of a client component are part of the page the browser receives.
Because it calls `cookies()`, like every use of `cookies()` in Next.js it makes the route dynamic. Render it in the
layout that needs it, not around the whole application, when other routes can be static.
## The development warning
A cookie definition rendered on the server below a provider that holds nothing for it renders its default, which is
usually a mistake: the visitor has a stored value, and the page shows something else until hydration. In development,
the server logs a warning once per key. The warning is logged when:
- the provider was given `listed` (as `PersistentStateProvider` does) and the definition's key is not in it, or
- the provider has no `listed` and its jar holds no cookie under any name of the key.
A definition that is listed but whose cookie the visitor does not have yet stays quiet, because its default is the correct
render. The warning is never logged in production, in the browser, or when there is no provider at all: without a
provider, rendering the default is the documented behavior.
## After hydration
Once React has hydrated, `getSnapshot` takes over. `definition.get()` loads the shared record, which reads storage
through the [read pipeline](/internals/read-pipeline/), evaluating `consent` on the way. When the result differs from
the server snapshot, React renders again with it. For a cookie definition this happens only when the cookie changed
between the request and hydration, or when `consent` returns `false`, because `readCookieState()` does not evaluate it.
# The read pipeline
> The steps that turn stored text into a value, in order, and what each failure yields.
This page explains how persistent-state turns what a storage holds into a value: the nine steps of the read pipeline,
their order and what each failure yields. It is for readers who need the exact behavior, for example to debug a stored
value that reads as the default, or to design a `version` and a `migrate`. Writing is covered in
[The write pipeline](/internals/write-pipeline/), and the text itself in [Stored format](/internals/stored-format/).
The pipeline is in `src/core/state.ts` (`read`, `readStorage`, `readCookie`, `decode` and `resolve`). The envelope helpers
are in `src/core/envelope.ts` and the cookie helpers in `src/core/cookie.ts`.
## When it runs
The pipeline runs:
- on the first `get()` in a page, for a Web Storage or cookie definition;
- on a cross-tab update, as described in [Cross-tab sync](/internals/cross-tab-sync/);
- after `refreshConsent()` finds that consent was granted and there is no unsaved in-memory value;
- in `readCookieState()` and `readState()`, on the server, with the cookies of the request.
Every other `get()` returns the value cached in the [shared record](/internals/shared-record/).
## The steps
| Step | Name | What happens | On failure |
| ---- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| 1 | Consent | In the browser, when `consent` returns `false`, storage is not read. The value is the default, or the value set in memory this page. | Not an error; `isPersistent()` is `false`. |
| 2 | Raw text | Web Storage: `getItem(fullKey)`. Cookies: the first present name among the [cookie names](#cookie-names). No text means the default. Storage that cannot be used yields the default. | `unavailable`, the default. |
| 3 | Decode | With a `serializer`, `deserialize(text)`. Otherwise `JSON.parse(text)`, with the [legacy rule for raw strings](#the-legacy-rule). | `parse-failed` when `deserialize` throws. |
| 4 | Envelope | A parsed object with `__ps: 1` is an [envelope](/internals/stored-format/#the-envelope). An object whose `__ps` is anything else was written by newer code. Anything else is a plain value with version `0` and no expiry. | `version-ahead`, or `parse-failed` when malformed. |
| 5 | Version ahead | A stored version above `version` yields the default and leaves the item untouched. | `version-ahead`, the default. |
| 6 | Expiry | A Web Storage envelope past `exp` yields the default, and the item is removed. The `exp` of a cookie is ignored; the browser expires cookies. | None. |
| 7 | Migration | A stored version below `version` goes through `migrate(value, storedVersion)`. | `migrate-failed`, the default. |
| 8 | Validation | With a `schema`, `schema['~standard'].validate(value)`. The schema's output is the value. | `invalid` or `async-schema`, the default. |
| 9 | Write-back | In the browser, a migrated value is written back once, so migration runs once per stored item. | `write-failed`; the value is still used, and `isPersistent()` is `false`. |
The order matters in two places.
**Version ahead is checked before expiry.** An item written by a newer version of your code is never removed by older
code, even when it is past its `exp`: step 5 returns the default before step 6 can remove the item. Only an item of the
current version, or an older one, expires.
**A foreign `__ps` envelope reads as version-ahead.** Step 4 treats any object whose `__ps` property is not the number
`1` as written in a newer envelope format, such as `{"__ps":2,…}`, `{"__ps":"1",…}` or `{"__ps":null,…}`. It ranks above
every version, so step 5 reports `version-ahead` and yields the default, whether or not the definition sets `version`.
The item is never written back.
## What a failed read leaves behind
A read that yields the default because of `parse-failed`, `invalid`, `async-schema`, `migrate-failed` or `version-ahead`
does not write to or remove the stored item. Only two things change storage during a read: the removal of an expired Web
Storage item in step 6 and the write-back of a migrated value in step 9. `set()` replaces an unreadable item, and
`reset()` removes it, so withdrawing consent can delete it.
After a read that reports `version-ahead`, the value in the record is the default. A later `set(prev => …)` therefore
starts from the default, not from the stored value, and its result replaces the item written by newer code, under the
current `version`.
Step 2 in detail: cookie names, decoding and the unavailable case
#### Cookie names
A cookie may have been written under one of several names, so the lookup tries each in order and uses the first that is
present:
1. the canonical name, the key encoded as js-cookie encodes names;
2. the raw key;
3. `encodeURIComponent(key)`;
4. `encodeURIComponent(encodeURIComponent(key))`.
Names that cannot be a cookie name (anything outside printable ASCII, or containing `"`, `,`, `;`, `=` or `\`) are left
out. Writes use only the canonical name, so an older name stays in the jar until `reset()` removes every variant.
#### The text
For cookies, the value of a cookie is URI-decoded once when the cookie jar is parsed, as js-cookie reads it. If JSON
parsing then fails, the decoder tries up to two more passes of the same decoding before it gives up and uses the text
as it is. This reads cookies written by 0.2.1 under single or double encoding. A lone `%` is not an escape and stays as
written. A percent-escape that is not valid UTF-8, such as `%E0%A4`, cannot be decoded: the read reports `parse-failed`,
yields the default and leaves the cookie in place. Surrounding double quotes are kept, because Next.js's request
cookies keep them, and the browser and the server must decode a value the same way. For Web Storage there are no extra
passes.
#### Unavailable storage
Before the first read, storage is probed once per page: a Web Storage `setItem` and `removeItem` of a probe key, or
`navigator.cookieEnabled` for cookies. A probe that throws a quota error still counts as available, so stored values of
a full storage can be read and removed. When the probe fails, or when reading itself throws, the pipeline reports
`unavailable` (with the thrown error as `cause`, if any), the value lives in memory and `isPersistent()` is `false`. The
report is made once per record, however often the record is read again.
## The legacy rule
Version 0.2.1, the last version before the first public release, stored strings raw. Step 3 therefore reads both
generations of data, as [Migrating](/help/migrating/) describes:
- When `JSON.parse` fails, the raw text is the value. A stored `dark` reads as `'dark'`.
- When the `default` is a string and the parsed value is not a string, the raw text is the value. A stored `42` reads as
`'42'` for a string definition, and `true` reads as `'true'`.
A definition whose default is not a string, such as a `null` default typed `string | null`, reads a raw 0.2.1 value that
parses as JSON (`42`, `true`) as that number or boolean. With a `schema` that expects a string, it is reported as
`invalid`. Such a definition should re-save its value, or use a string default.
## Validation
Step 8 calls `schema['~standard'].validate(value)` once.
| Result | Reported | Value |
| ------------------------------------------- | ------------------------ | -------------- |
| Success | nothing | `result.value` |
| Issues, or a falsy result | `invalid` | the default |
| `validate` threw | `invalid` (with `cause`) | the default |
| A promise (anything with a `then` function) | `async-schema` | the default |
A schema must accept its own output. `set()` stores the value as it is, and the next read passes the stored value
through the schema again, so a schema that transforms a value into a different shape rejects what it produced. The
default is not validated.
## Server reads
`readCookieState()` runs the same steps for a cookie definition, with the cookies of the request as step 2. It differs in
three ways: it does not evaluate the `consent` predicate, because a cookie exists only if it was written with consent; it
does not probe storage; and it never writes back a migrated value. Failures still go to the definition's `onError`.
# The shared record
> How every definition with the same storage and key shares one in-memory record, and why the registry lives on globalThis.
This page explains where the state of a definition lives while the page is open: the shared record, the registry that
holds it and the page-level listeners that keep it current. It is for readers who need the exact behavior, for example
to understand why two definitions of one key stay in sync, or why a hot reload does not lose pending writes. Reading and
writing storage are covered in [The read pipeline](/internals/read-pipeline/) and
[The write pipeline](/internals/write-pipeline/).
The record and the registry are in `src/core/state.ts`. The definition object they serve is created by
`persistentState()` and typed in `src/core/types.ts`.
## What a definition holds
A definition returned by `persistentState()` holds no value and no listener of its own. It is a set of closures over the
options you passed: the full key, the storage, the schema, the serializer and the rest. Declaring a definition therefore
does not touch storage, does not register a listener and does not create a record. Its only effects are the validation of
the options, which throws a `TypeError` for an empty key, an invalid `expires` or `version`, and a `version` without
`migrate` (or the reverse), and the registration for server reads described next.
For server reads, `persistentState()` also registers the definition in a module-level `WeakMap` (`readers`) that holds a
function reading the definition's cookie from any source. `readCookieState()` looks the definition up there, which is why
it throws a `TypeError` for an object that was not created by `persistentState()`.
## The registry
The first browser call that needs state creates a record and stores it in the registry, an object kept on
`globalThis.__persistentState`:
| Field | Holds |
| ----------- | -------------------------------------------------------------------------------------------------- |
| `records` | A `Map` from a record id to its record. |
| `available` | The result of the availability probe for each storage, so each storage is probed once per page. |
| `flushing` | Whether the `pagehide` and `visibilitychange` listeners that flush debounced writes are installed. |
| `channel` | The `BroadcastChannel` used for cookie sync, or `null` when the browser has none. |
The registry is on `globalThis` rather than in a module variable so that a module evaluated again keeps the same state.
Hot module reloading evaluates the module that declares a definition again; the new definition finds the records,
timers, listeners and probe results of the old one instead of starting over, and a write that was waiting for its
debounce still flushes.
## The record
A record is created lazily, on the first `get()`, `set()`, `reset()`, `subscribe()` or `isPersistent()` of any definition
with its id, in the browser. The id is `` `${storage}:${fullKey}` ``, where the full key includes the namespace. The
cookie `theme` and the `localStorage` item `theme` are therefore different records, and `{ namespace: 'app' }` with the
key `theme` is `app:theme`.
| Field | Meaning |
| ----------------------- | --------------------------------------------------------------------------------------------------- |
| `storage` | The storage the record belongs to. |
| `value` | The current value, starting at the definition's default. |
| `loaded` | Whether the first read has happened. The first `get()` loads the record. |
| `persistent` | What `isPersistent()` returns. `false` while the value lives only in memory. |
| `granted` | The last result of the `consent` predicate. Compared by `refreshConsent()`. |
| `unsaved` | Whether the value was set in memory and has not reached storage yet. |
| `owner` | The definition that last touched the record. It handles consent refreshes and cross-tab re-reads. |
| `listeners` | The functions passed to `subscribe()`. |
| `timer`, `pendingWrite` | The debounce timer and the write it will run. See [The write pipeline](/internals/write-pipeline/). |
Creating the first record of a page also installs the page-level listeners: a `storage` listener on `window` and a
`visibilitychange` listener on `document`. The first cookie record also opens the `BroadcastChannel`. They are installed
once. See [Cross-tab sync](/internals/cross-tab-sync/).
On the server there is no `document`, so no record is ever created: `get()` returns the default, `subscribe()` returns a
no-op, `isPersistent()` returns `false`, and `set()` and `reset()` throw.
## Several definitions of one key
Every definition with the same storage and full key shares one record, so they share one value, one set of listeners,
one debounce timer and one persistence flag. A `set()` through any of them notifies the subscribers of all of them, and
two components that call `useStoredState()` with different definitions of one key re-render together.
The options of each definition still apply to its own reads and writes: a write through a definition uses that
definition's serializer, version, expiry and cookie attributes. Two things belong to the record instead, and follow the
`owner`, the definition that touched the record last: the consent predicate that `refreshConsent()` evaluates, and the
options (`sync`, `consent`, the schema and the rest) that a cross-tab re-read uses. Declare each key once, and import the
definition where it is needed, so the two never differ.
Why the first get() reads, and what isPersistent() triggers
`get()` and `isPersistent()` both load the record, so asking whether a value is persistent reads storage if nothing has
read it yet. A first `set()` or `reset()` also calls `get()` before it changes anything, so that an updater function
receives the stored value and not the default, and so that read failures such as `unavailable` or `version-ahead` are
reported before the write.
# Stored format
> The text persistent-state stores, the envelope for versions and expiry, and the js-cookie-compatible cookie encoding.
This page explains exactly what persistent-state writes: the stored text, the envelope that carries a version and an
expiry, and how cookie names and values are encoded. It is for readers who need the exact bytes, for example to read a
value from another runtime, to inspect storage in the browser's developer tools, or to keep cookies compatible with
code written for an earlier version.
The envelope is in `src/core/envelope.ts`, the cookie encoding in `src/core/cookie.ts` and the encoding step in
`src/core/state.ts` (`encode` and `store`). Values stored by this package are readable and writable by the visitor and by
any script on the page; they are not encrypted or signed.
## The stored text
Without a `version` and without a Web Storage expiry, the stored text is the serialized value. With the default JSON
serializer, every value is stored through `JSON.stringify`, strings included, so the string `"42"` is stored with its
quotes and reads back as a string, not a number.
| Value | Stored text |
| ---------------- | --------------- |
| `'dark'` | `"dark"` |
| `'42'` | `"42"` |
| `42` | `42` |
| `{ open: true }` | `{"open":true}` |
With a custom `serializer`, the stored text is whatever `serialize(value)` returns, with no JSON wrapping. Reads
accept the raw text 0.2.1 wrote for strings; see [the legacy rule](/internals/read-pipeline/#the-legacy-rule).
## The envelope
When the definition sets `version`, or sets a Web Storage `expires` other than `0`, the stored text is a JSON envelope
instead:
```json
{ "__ps": 1, "v": { "mode": "dark" }, "ver": 2, "exp": 1792000000000 }
```
| Field | Meaning |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `__ps` | The envelope format. Always `1` in this version. |
| `v` | The value. With a custom `serializer`, the serialized string. |
| `ver` | The `version` the value was written under. Omitted when there is no `version`, and read as `0`. |
| `exp` | The expiry as epoch milliseconds. Written only for Web Storage with `expires` set; cookies expire through their own attribute and have none. |
Absent fields are omitted, so `{"__ps":1,"v":1,"exp":1792000000000}` is a versionless value with an expiry.
On read, a value that is not an envelope is a plain value with version `0` and no expiry. A plain value written before
`version` or `expires` was configured is therefore still read, passes through `migrate` as version `0`, and never
expires.
An envelope is malformed, and reported as `parse-failed`, when it has no `v`, when `ver` is not a non-negative integer,
when `exp` is not a finite number, or, with a custom serializer, when `v` is not a string. An object whose `__ps` is
anything other than `1` is not malformed: it was written in a newer envelope format and reads as `version-ahead`. See
[The read pipeline](/internals/read-pipeline/).
Why not store the version and expiry separately?
A Web Storage item is one string under one key, and a cookie is one name and value. Keeping the version and expiry in the
same text means a value, its version and its expiry always change together, in one write, in every storage. Without
either, the stored text stays the plain serialized value, so code written for 0.2.1, which reads the text with
`JSON.parse`, keeps working while projects migrate.
## Cookie encoding
Cookie names and values are encoded exactly as js-cookie 3 encodes them. persistent-state does not depend on js-cookie;
the encoding is part of `src/core/cookie.ts`. The goal is compatibility in both directions: a cookie written by 0.2.1,
which wrote cookies in the browser with js-cookie, through `next-client-cookies`, reads in this version, and a cookie
written here reads with 0.2.1's `getPersistentCookieItem`. The test fixtures carry a copy of that function to prove it.
**Names** are encoded with `encodeURIComponent`. Then `#`, `$`, `&`, `+`, `^`, the backtick and `|` are restored to the
literal character, and `(` and `)` are escaped as `%28` and `%29`.
**Values** are encoded with `encodeURIComponent`. Then the characters js-cookie leaves readable are restored: `#`, `$`,
`&`, `+`, `/`, `:`, `<`, `=`, `>`, `?`, `@`, `[`, `]`, `^`, the backtick, `{`, `|` and `}`.
| Key | Canonical cookie name | Value written | Cookie value as stored |
| ----------- | --------------------- | ----------------------- | --------------------------------- |
| `theme` | `theme` | `"dark"` | `%22dark%22` |
| `app:theme` | `app%3Atheme` | `{"open":true,"w":280}` | `{%22open%22:true%2C%22w%22:280}` |
| `a(b)` | `a%28b%29` | `1` | `1` |
Reading reverses this: the cookie jar is parsed from `document.cookie` (or a `Cookie` header), and each value is
URI-decoded once, as js-cookie's read converter does. Names are compared as written.
### Cookie attributes
A cookie write sets these attributes, in this order:
| Attribute | Value |
| ---------- | --------------------------------------------------------------------------------------------------- |
| `path` | The `path` option, `/` by default. |
| `expires` | `Date.now()` plus `expires` days, or the `Date` given. Omitted for a session cookie (`expires: 0`). |
| `samesite` | The `sameSite` option, `lax` by default. |
| `domain` | The `domain` option. Omitted by default, which limits the cookie to the current host. |
| `secure` | Added when the `secure` option is `true`, which is the default on `https:` pages. |
The `path` and `domain` values are cut at the first `;`, so an option cannot add attributes of its own. A number of days
is recomputed on every write, and the default for cookies is `365` days. `expires: 0` writes no `Expires` attribute, so
the browser keeps the cookie until it closes; for Web Storage the same value means no expiry.
### Names an older version may have written
A cookie written by an older version may sit under the raw key, `encodeURIComponent(key)` or the double-encoded key
instead of the canonical name. Reads try the canonical name first and then those, and `reset()` removes every variant.
A write only ever uses the canonical name, so it does not remove an older variant; the canonical cookie wins from then
on, because it is tried first.
Cookie size and how a rejected cookie is detected
Browsers silently ignore a cookie they reject, for example one over their size limit, or `SameSite=None` without
`Secure`. `document.cookie = …` does not throw in that case, so after every write the cookie jar is read back and the
value compared with the text written. A mismatch is reported as `write-failed`, and the value stays in memory. Keep
cookie values small: they are sent with every request to the cookie's path.
# The write pipeline
> What set() and reset() do, how debounced writes are flushed when the page is hidden, and how write failures are reported.
This page explains what happens between a call to `set()` and the bytes in storage: the steps of the write pipeline, how
`debounceMs` delays a write and when a delayed write is flushed, and how a failed write is reported. It is for readers
who need the exact behavior, for example to decide whether to debounce a value that changes on every keystroke, or to
understand why `isPersistent()` changed. Reading is covered in [The read pipeline](/internals/read-pipeline/).
The pipeline is in `src/core/state.ts` (`set`, `write`, `store`, `remove`, `flush` and `flushOnHide`). The text it writes
is described in [Stored format](/internals/stored-format/).
## The steps of `set()`
`set(next)` runs these steps in order. A call on the server throws, because there is no storage to write.
1. **Resolve the value.** The first `set()` in a page loads the [shared record](/internals/shared-record/) so that an
updater function receives the stored value. `next` is the new value, or the result of `next(previous)`. A value of
`undefined` is the same as `reset()`. A value that is `Object.is`-equal to the current one does nothing, so setting a
new object with the same contents writes, and setting the same object does not.
2. **Update memory and notify.** The record takes the value, is marked as unsaved, and every subscriber is notified
synchronously. Components re-render before anything is written.
3. **Cancel the pending write.** A write that was waiting for its debounce is replaced by this one.
4. **Write now, or later.** With `debounceMs` of `0`, the write runs at once. With a larger value, it is scheduled; see
[Debounce and flush](#debounce-and-flush).
5. **Write.** The `consent` predicate is evaluated, and storage availability is checked. If consent is withheld or
storage cannot be used, nothing is written, and `isPersistent()` becomes `false`. Otherwise the value is stored.
6. **Store.** The value is serialized with JSON or the `serializer`, wrapped in an envelope when `version` is set or a
Web Storage `expires` is set, and written. A cookie is written to `document.cookie` and then read back to confirm it
was accepted.
7. **Report the outcome.** A successful write clears the unsaved mark and sets `isPersistent()` to `true`; a failed one
sets it to `false`. Subscribers are notified when `isPersistent()` changed.
8. **Announce a cookie write.** After a successful cookie write, a message is posted on the `BroadcastChannel` so other
tabs re-read. Web Storage writes need no message, because the browser fires `storage` events in the other tabs.
## Write failures
A write that fails never throws. It is reported to `onError` as `write-failed`, the value stays in memory, and
`isPersistent()` is `false` until a later write succeeds. A write fails when:
- `setItem` throws, for example because the storage is full (`QuotaExceededError`);
- a cookie does not read back from `document.cookie`, because the browser blocked it or the cookie is over its size
limit;
- the value cannot be serialized, for example a `serializer.serialize` that throws, or a value `JSON.stringify` rejects.
The error carries a `cause` when something threw; a cookie that does not read back has no underlying error. A `set()`
that finds storage unavailable reports `unavailable` when the record loads, as described in
[The read pipeline](/internals/read-pipeline/).
## `reset()`
`reset()` loads the record if needed, cancels a pending write, sets the value to the default, clears the unsaved mark,
notifies subscribers and removes the stored item. A cookie is removed under every name an older version may have written
it, using the definition's `path`, `domain`, `secure` and `sameSite`, and a message is posted on the
`BroadcastChannel`. A Web Storage item is removed with `removeItem`. Removal does not evaluate the `consent` predicate:
a visitor who withdraws consent can have the stored item deleted, while the predicate returns `false`. It notifies
subscribers even when the value was already the default.
## Debounce and flush
With `debounceMs` greater than `0`, step 4 does not write. It stores the write as the record's `pendingWrite` and starts a
timer for `debounceMs` milliseconds. A newer `set()` cancels both and starts again, so a value that changes on every
keystroke is written once, `debounceMs` after the last change.
While a write is pending:
- memory and subscribers already hold the new value;
- `isPersistent()` keeps its previous value, because only the write changes it;
- the `consent` predicate is evaluated when the write runs, not when `set()` was called;
- changes made by other tabs are ignored for this record, so they cannot overwrite the pending value;
- `reset()` discards the write.
The first debounced `set()` of a page installs two listeners, once per page:
| Listener | Fires when | Does |
| -------------------------------- | ----------------------------------- | --------------------------------------------------- |
| `pagehide` on `window` | The page is navigated away from. | Writes every pending write of every record at once. |
| `visibilitychange` on `document` | The tab's visibility state changes. | Does the same when the state is `hidden`. |
Flushing writes each pending value immediately, through the same `write` step as any other write. There is no
`beforeunload` or `unload` listener, so using persistent-state does not make a page ineligible for the browser's
back/forward cache.
What is not flushed
A pending write is lost if the page is destroyed without a `pagehide` or `visibilitychange` to `hidden`, for example when
the browser process is killed. For values that must not be lost, leave `debounceMs` at `0`.
# API reference
> Every export of persistent-state, grouped by entry point, with its signature, options, return value and errors.
This page lists every export persistent-state ships, grouped by entry point. Each entry gives the signature, the options
with their types and defaults, the return value and the errors it throws. Options are optional unless marked
**Required**. The guides explain when to use each part, and [How it works](/internals/shared-record/) explains what the
code does underneath.
| Entry point | Runs in | Exports |
| ------------------------------------------------------------------------------ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`@nicolopadovan/persistent-state`](#nicolopadovanpersistent-state) | browser, Node, edge | `persistentState`, `refreshConsent`, `readCookieState`, `PersistentStateError`, the types `PersistentState`, `PersistentStateOptions`, `StorageKind`, `CookieOptions`, `CookieSource`, `Serializer`, `StandardSchemaV1`, and the deprecated exports |
| [`@nicolopadovan/persistent-state/react`](#nicolopadovanpersistent-statereact) | browser and React SSR | `useStoredState`, `usePersistentState`, `CookieValuesProvider`, the types `StoredStateMeta`, `UsePersistentStateOptions`, `CookieValuesProviderProps` |
| [`@nicolopadovan/persistent-state/next`](#nicolopadovanpersistent-statenext) | Next.js server (App Router) | `PersistentStateProvider`, `readState`, the type `PersistentStateProviderProps` |
The core imports nothing from React. The `/react` entry point starts with a `'use client'` directive. The `/next` entry
point imports `next/headers` and must only be imported by Server Components, Server Actions and Route Handlers.
:::caution[0.x releases may break]
Every 0.x minor release may contain breaking changes; patch releases never do. The exports marked **Deprecated** are
removed in 1.0.0.
:::
## `@nicolopadovan/persistent-state`
### `persistentState(key, options)`
Declares one persisted key: its type, where it is stored and how it is stored, in one place. Call it once at module
scope, in a module that both server and client code can import (a module without `'use client'`), and export the result.
Declaring a key reads and writes nothing; storage is first used by `get()`, `set()`, `reset()`, `subscribe()` or
`isPersistent()` in the browser.
```ts
import { persistentState } from '@nicolopadovan/persistent-state'
function persistentState(
key: string,
options: PersistentStateOptions, S> & { schema: StandardSchemaV1 },
): PersistentState
function persistentState(
key: string,
options: PersistentStateOptions & { schema?: undefined },
): PersistentState
```
`T` is the output type of `schema` when a schema is given, and the type of `default` otherwise. `S` is the type of
`storage`.
```ts
export const theme = persistentState('theme', { default: 'system', storage: 'cookie' })
```
| Option | Type | Default | Description |
| ------------ | --------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `default` | `T` | **Required** | The value used when nothing valid is stored. It is treated as immutable and is never validated by `schema`. |
| `storage` | `StorageKind` | `'localStorage'` | Where the value is stored. Only `'cookie'` is readable on the server. |
| `schema` | `StandardSchemaV1` | none | Validates every value read from storage. Synchronous schemas only. The schema must accept its own output. See [Schema](#schema). |
| `version` | `number` (an integer, `1` or more) | none | The current shape version. A stored value with a lower version goes through `migrate`; one with a higher version is reported as `version-ahead`. Requires `migrate`. |
| `migrate` | `(stored: unknown, fromVersion: number) => T` | none | Converts a value stored under an older version to the current one. `fromVersion` is `0` for a value stored without a version. Requires `version`. |
| `expires` | `number` (days) or `Date` | cookie: `365`; Web Storage: none | When the value expires. See [Expiry](#expiry). |
| `namespace` | `string` | none | A prefix. The stored name becomes `` `${namespace}:${key}` ``, and `definition.key` returns it. |
| `debounceMs` | `number` | `0` | Delays storage writes by this many milliseconds. Memory and subscribers update at once. Pending writes are flushed when the page is hidden. |
| `sync` | `boolean` | `true` | Follows changes made in other tabs. `sessionStorage` is never synced. |
| `serializer` | `Serializer` | JSON | Converts values to and from the stored text. |
| `consent` | `() => boolean` | none (always allowed) | While it returns `false`, storage is neither read nor written. See [Consent](#consent). |
| `onError` | `(error: PersistentStateError) => void` | `console.warn('[persistent-state]', message, error)` | Receives every read, write, validation and migration failure. Pass `() => {}` to silence it. Exceptions it throws are not caught. |
| `path` | `string` | `'/'` | Cookie only. The cookie's `Path`. |
| `domain` | `string` | none | Cookie only. The cookie's `Domain`. Without it, the cookie is limited to the current host. |
| `secure` | `boolean` | `true` on `https:` pages, otherwise `false` | Cookie only. Whether the cookie is sent only over HTTPS. |
| `sameSite` | `'strict' \| 'lax' \| 'none'` | `'lax'` | Cookie only. The cookie's `SameSite` attribute. |
The cookie-only options are typed `never` when `storage` is not `'cookie'`, so `persistentState('k', { default: 0, path: '/' })`
is a type error.
**Returns** a [`PersistentState`](#the-definition-object) definition.
**Throws** a `TypeError` when:
- `key` is empty.
- `expires` is a negative or non-finite number, or an invalid `Date`.
- `version` is not an integer of `1` or more.
- `version` is set without `migrate`, or `migrate` without `version`.
No other call throws because of storage. Every storage failure goes to `onError`. An `onError` that throws is caught and
logged with `console.error`, so its error never reaches the caller.
#### Schema
`schema` accepts any [Standard Schema](https://standardschema.dev) (zod 4, valibot, arktype and others) whose
`validate` returns synchronously. The value type is the schema's output type, so a `default` or a `migrate` result that
does not match it is a type error.
```ts
import { z } from 'zod'
const Theme = z.enum(['light', 'dark', 'system'])
export const theme = persistentState('theme', { default: 'system', storage: 'cookie', schema: Theme })
// theme.get(): 'light' | 'dark' | 'system'
```
A stored value the schema rejects is reported as `invalid` with the schema's `issues`, and the default is used. A
schema that returns a promise is reported as `async-schema`.
A schema must accept its own output
`set()` stores the value you pass as it is; it does not run it through the schema. The next read validates the stored
value, and the schema's output replaces it. A schema that changes the shape of a value, such as a `transform` that turns
a string into a number, therefore sees its own output on the next read and rejects it. Every value that `set()` stores
and every value that `migrate` returns must pass `schema` unchanged. When it does not, the read reports `invalid` and
yields the default.
```ts
// Rejects its own output: the stored number is not a string
const bad = z.string().transform(Number)
// Accepts its own output: the stored object is an object
const good = z.object({ mode: Theme, size: z.number().default(1) })
```
`default` is never validated, so it does not have to pass the schema, but it is the value every failed read yields.
#### Expiry
`expires` is a number of days, counted from each write, or a fixed `Date`.
| Storage | Behavior |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cookie` | The cookie's `Expires` attribute. A number defaults to `365` days. `0` writes a session cookie, which the browser removes when it closes. |
| `localStorage`, `sessionStorage` | The value is stored in an envelope that holds its expiry and is removed on the first read after it. `0`, or no value, means it never expires and the value is stored without an envelope. |
A number is recomputed on every write, so each `set()` extends the expiry. A `Date` is fixed. `expires: 0` therefore
means no expiry for Web Storage but a session cookie for cookies.
#### Consent
`consent` is a plain predicate, so persistent-state does not depend on any consent manager. While it returns `false`:
- storage is neither read nor written;
- the value lives in memory, starting at `default`;
- `isPersistent()` is `false`.
The predicate is evaluated on first use, before every write and by [`refreshConsent()`](#refreshconsent). `reset()`
removes the stored item even while the predicate returns `false`. No stored item is deleted automatically when it
returns `false`, because a consent manager that has not loaded yet may return `false` for a moment. A predicate that
throws counts as `false`, and the error is logged with `console.error`.
Overloads, and forwarding options through a wrapper
The two overloads keep a `default`, or a `migrate` result, that does not match the schema's output a type error. A
single signature over `PersistentStateOptions` would widen `T` and hide the mismatch.
The cost is that options typed `PersistentStateOptions` match neither overload, because the general type may or may
not carry a schema. A wrapper that forwards its options casts them to the second overload's parameter type:
```ts
import {
persistentState,
type PersistentStateOptions,
type StorageKind,
} from '@nicolopadovan/persistent-state'
export const appState = (key: string, options: PersistentStateOptions) =>
persistentState(key, { namespace: 'app', ...options } as PersistentStateOptions & { schema?: undefined })
```
The cast only silences the type check. The schema is still read from the options and honored at runtime; what is lost is
the check that `default` matches the schema's output.
### The definition object
`persistentState()` returns a `PersistentState`.
```ts
interface PersistentState {
readonly key: string
readonly storage: S
readonly defaultValue: T
get(): T
set(next: T | ((prev: T) => T)): void
reset(): void
subscribe(listener: () => void): () => void
isPersistent(): boolean
}
```
| Member | Description |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key` | The full stored name, namespace included. |
| `storage` | Where the value is stored. |
| `defaultValue` | The `default` option. |
| `get()` | Returns the current value. The first call in a page reads storage through the [read pipeline](/internals/read-pipeline/); later calls return the cached value, so an object keeps its identity. On the server it returns the default. |
| `set(next)` | Updates the value in memory, notifies subscribers synchronously, then writes it through the [write pipeline](/internals/write-pipeline/). A function receives the current value. A value `Object.is`-equal to the current one does nothing, and `undefined` is the same as `reset()`. Throws on the server. |
| `reset()` | Sets the value to the default, notifies subscribers and removes the stored item; a cookie is removed under every name an older version may have written it. It cancels a pending debounced write and removes the item even while `consent` returns `false`. Throws on the server. |
| `subscribe(listener)` | Calls `listener` when the value or `isPersistent()` changes. Returns the unsubscribe function. On the server it does nothing and returns a no-op. |
| `isPersistent()` | Whether the value is being stored. `false` while it lives only in memory (storage is unavailable, a write failed, or `consent` returns `false`) and on the server. |
Every definition with the same `storage` and full key shares one in-memory record, so they hold one value and notify
together. See [The shared record](/internals/shared-record/).
**Throws** an `Error` from `set()` and `reset()` on the server, that is, when there is no `document`. The message names
`readState()`: server writes are not supported yet.
After a read that reports version-ahead
A stored value written under a higher `version`, or in a newer envelope format, is reported as `version-ahead`, and
`get()` returns the default. The stored item is left untouched for the newer code that wrote it. Because `get()` returns
the default, `set(prev => …)` starts from the default, not from the stored value, and its result replaces the item
written by newer code. `reset()` removes it.
```ts
// storage holds {"__ps":1,"v":{"mode":"dark"},"ver":3}, and this code is at version 2
settings.get() // the default, and 'version-ahead' goes to onError
settings.set((prev) => ({ ...prev, size: 2 })) // prev is the default; the item is replaced at version 2
```
### `refreshConsent()`
Re-evaluates the `consent` predicate of every definition used in the page. Call it when the consent state changes.
```ts
import { refreshConsent } from '@nicolopadovan/persistent-state'
function refreshConsent(): void
```
For each definition whose predicate result changed:
- **To `true`**: a value set in memory since consent was withheld is written to storage. Otherwise the stored value is
read and subscribers are notified when the value changed.
- **To `false`**: the value stays in memory and `isPersistent()` becomes `false`. Nothing is deleted from storage; call
`reset()` on a definition to remove its item.
A definition that was never used in the page is not evaluated, because it evaluates the predicate on first use. On the
server, `refreshConsent()` does nothing.
```ts
consentManager.subscribe((state) => {
if (!state.preferences) theme.reset()
refreshConsent()
})
```
**Returns** nothing. **Throws** nothing.
### `readCookieState(definition, cookies)`
The framework-free server read for cookie definitions, for any runtime. In Next.js, use [`readState`](#readstatedefinition)
instead.
```ts
import { readCookieState } from '@nicolopadovan/persistent-state'
function readCookieState(definition: PersistentState, cookies: CookieSource | string): T
interface CookieSource {
get(name: string): { value: string } | undefined
}
```
| Parameter | Type | Description |
| ------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `definition` | `PersistentState` | **Required.** A definition created by `persistentState()` with `storage: 'cookie'`. A definition for another storage is a type error. |
| `cookies` | [`CookieSource`](#cookiesource) \| `string` | **Required.** The request's cookies: an object with `get(name)` whose value is URI-decoded once, such as Next.js's `cookies()`, or a `Cookie` header string, which is parsed for you. |
**Returns** the stored value, decoded, migrated and validated like a browser read, or the definition's default when no
cookie holds a valid value. It does not write back a migrated value and does not evaluate the `consent` predicate:
cookies exist only if they were written with consent. Failures go to the definition's `onError`.
**Throws** a `TypeError` when `definition` was not created by `persistentState()`.
### `PersistentStateError`
The error passed to `onError`. No public function throws it.
```ts
import { PersistentStateError } from '@nicolopadovan/persistent-state'
class PersistentStateError extends Error {
constructor(
code: PersistentStateError['code'],
key: string,
details?: { cause?: unknown; issues?: readonly StandardSchemaV1.Issue[] },
)
readonly code:
| 'unavailable'
| 'write-failed'
| 'parse-failed'
| 'invalid'
| 'async-schema'
| 'migrate-failed'
| 'version-ahead'
readonly key: string
readonly issues?: readonly StandardSchemaV1.Issue[]
// `cause` holds the underlying error
}
```
| Property | Description |
| --------- | ------------------------------------------------------------------------------------- |
| `code` | What failed. See the codes below. |
| `key` | The full stored name of the definition, namespace included. |
| `issues` | The issues the schema reported. Set for `invalid` when the schema rejected the value. |
| `cause` | The underlying error, when there is one. |
| `name` | `'PersistentStateError'`. |
| `message` | `"": ` followed by a sentence describing the code. |
The seven codes:
| Code | Reported when | The value is |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `unavailable` | The storage cannot be used: the probe write to Web Storage threw something other than a quota error, `navigator.cookieEnabled` is `false`, or reading threw (then `cause` is set). Reported once per key; probed once per page. | Kept in memory for the page; `isPersistent()` is `false`. |
| `write-failed` | `setItem` threw, for example a quota error. Or a cookie did not read back from `document.cookie` (blocked, or over the browser's size limit). Or the value could not be serialized. Also reported when writing back a migrated value. | Kept in memory; `isPersistent()` is `false` until a later write succeeds. |
| `parse-failed` | `serializer.deserialize` threw, or the stored envelope is malformed: it has no `v`, a `ver` that is not a non-negative integer, an `exp` that is not a finite number, or, with a custom serializer, a `v` that is not a string. Also a cookie value with a percent-escape that is not valid UTF-8, such as `%E0%A4`. | The default. |
| `invalid` | The schema reported issues (see `issues`), returned nothing, or threw (then `cause` is set). | The default. |
| `async-schema` | The schema's `validate` returned a promise. Only synchronous schemas are supported. | The default. |
| `migrate-failed` | `migrate` threw or returned `undefined`. | The default. |
| `version-ahead` | The stored item was written under a `version` higher than the current one, or in a newer envelope format: an object whose `__ps` is anything other than `1` reads as version-ahead, whether or not the definition sets `version`. | The default. |
A read never writes to or removes the stored item, with two exceptions: an expired Web Storage item is removed, and a
migrated value is written back once. A read that yields the default because of `parse-failed`, `invalid`,
`async-schema`, `migrate-failed` or `version-ahead` therefore leaves the item in place. `set()` replaces it and
`reset()` removes it.
Reading the error in onError
`PersistentStateError['code']` is the union of the seven codes; the union has no exported name of its own.
```ts
persistentState('settings', {
default: { mode: 'light' },
schema: Settings,
onError(error) {
if (error.code === 'invalid') report(error.key, error.issues)
else if (error.code !== 'version-ahead') report(error.key, error.cause)
},
})
```
### Types
#### `StorageKind`
```ts
type StorageKind = 'cookie' | 'localStorage' | 'sessionStorage'
```
Where a value is stored. Only `'cookie'` is readable on the server.
#### `PersistentStateOptions`
```ts
type PersistentStateOptions = CommonOptions &
(S extends 'cookie' ? CookieOptions : { [K in keyof CookieOptions]?: never })
```
The options of [`persistentState()`](#persistentstatekey-options). `CommonOptions` is the table of options above without
the four cookie attributes.
#### `CookieOptions`
```ts
interface CookieOptions {
path?: string
domain?: string
secure?: boolean
sameSite?: 'strict' | 'lax' | 'none'
}
```
The attributes of the cookie a `'cookie'` definition writes. Each one is applied on every write and on removal.
#### `CookieSource`
```ts
interface CookieSource {
get(name: string): { value: string } | undefined
}
```
Read access to request cookies, shaped like the cookie store of Next.js's `cookies()`. `get` returns the cookie with
exactly this name, its value URI-decoded once, or `undefined`.
#### `Serializer`
```ts
interface Serializer {
serialize(value: T): string
deserialize(text: string): unknown
}
```
Converts values to and from the stored text. A `deserialize` that throws is reported as `parse-failed` and the default is
used. The result of `deserialize` still goes through `migrate` and `schema`.
```ts
export const day = persistentState('day', {
default: new Date(0),
serializer: {
serialize: (value) => value.toISOString(),
deserialize: (text) => new Date(text),
},
})
```
#### `StandardSchemaV1`
The Standard Schema interface (version 1), vendored from `@standard-schema/spec` 1.1.0 so the package has no
dependency. zod 4, valibot and arktype schemas satisfy it. persistent-state calls `schema['~standard'].validate(value)`.
### Deprecated exports
The exports below keep the behavior they had in 0.2.1 and are removed in 1.0.0.
#### `getPersistentCookieItem(cookieStore, key)`
**Deprecated.** Use [`readCookieState`](#readcookiestatedefinition-cookies) with a definition from `persistentState()`.
```ts
function getPersistentCookieItem(cookieStore: PersistentCookieStore, key: string): T | undefined
```
Returns the JSON value of the cookie stored for `key`, trying the value as it is, then URI-decoded once and twice.
**Returns** `undefined` when no cookie is found, the value is empty, or none of the forms is JSON. It does not use
`default`, `schema` or `migrate`, and it does not unwrap envelopes.
**Throws** a `URIError` when the cookie value contains a malformed percent escape.
#### `getPersistentCookieValue(cookieStore, key)`
**Deprecated.** Use [`readCookieState`](#readcookiestatedefinition-cookies).
```ts
function getPersistentCookieValue(cookieStore: PersistentCookieStore, key: string): string | undefined
```
Returns the raw value of the cookie stored for `key`, looking under its raw name, its `encodeURIComponent` name and its
double-encoded name, in that order.
#### `PersistentCookieStore`
**Deprecated.** An alias of [`CookieSource`](#cookiesource).
#### `StorageType`
**Deprecated.** An alias of [`StorageKind`](#storagekind).
## `@nicolopadovan/persistent-state/react`
### `useStoredState(definition)`
Uses a definition in a component. Every component that uses a definition with the same storage and key shares one value
and re-renders together.
```tsx
'use client'
import { useStoredState } from '@nicolopadovan/persistent-state/react'
function useStoredState(
definition: PersistentState,
): [value: T, setValue: (next: T | ((prev: T) => T)) => void, meta: StoredStateMeta]
const [theme, setTheme, { reset, isPersistent }] = useStoredState(themeState)
```
| Parameter | Type | Description |
| ------------ | ----------------------- | ---------------------------------------------------------- |
| `definition` | `PersistentState` | **Required.** A definition created by `persistentState()`. |
**Returns** a tuple:
| Element | Type | Description |
| ---------- | --------------------------------------- | ------------------------------------------------------------- |
| `value` | `T` | The current value. |
| `setValue` | `(next: T \| ((prev: T) => T)) => void` | The definition's `set`. It keeps its identity across renders. |
| `meta` | [`StoredStateMeta`](#storedstatemeta) | `reset` and `isPersistent`. |
During server rendering and hydration, a Web Storage definition renders its default, and a cookie definition renders the
value from the nearest [`CookieValuesProvider`](#cookievaluesprovider) or, without one, its default. The stored
value follows right after hydration, so hydration never mismatches. See [Hydration snapshots](/internals/hydration/).
**Throws** nothing. `setValue` and `reset` throw only if called on the server.
### `usePersistentState(key, initial, options?)`
Persists state for a one-off key without declaring it with `persistentState()`. Prefer a definition for a key that is
used in more than one place or read on the server.
```tsx
'use client'
import { usePersistentState } from '@nicolopadovan/persistent-state/react'
function usePersistentState(
key: string,
initial: T | (() => T),
options?: UsePersistentStateOptions,
): [value: T, setValue: (next: T | ((prev: T) => T)) => void, meta: StoredStateMeta]
const [open, setOpen] = usePersistentState('sidebar-open', true)
```
| Parameter | Type | Default | Description |
| --------- | --------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------ |
| `key` | `string` | | **Required.** The stored name, prefixed by `namespace` when one is given. |
| `initial` | `T \| (() => T)` | | **Required.** The default value, or a function that returns it. A function is called once. |
| `options` | [`UsePersistentStateOptions`](#usepersistentstateoptions) | none | Every `persistentState()` option except `default`, plus the deprecated aliases. |
**Returns** the same tuple as [`useStoredState`](#usestoredstatedefinition).
The hook builds a definition the first time it renders and again whenever the full key or the storage changes. Other
options are read when the definition is built, so a change to them in a later render has no effect.
#### `UsePersistentStateOptions`
```ts
type UsePersistentStateOptions = Omit<
PersistentStateOptions,
'default'
> & {
storageType?: S
syncAcrossTabs?: boolean
}
```
| Option | Type | Description |
| ---------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `storageType` | `S` | **Deprecated.** The 0.2.1 name of `storage`. `storage` wins when both are set. Removed in 1.0.0. |
| `syncAcrossTabs` | `boolean` | **Deprecated.** The 0.2.1 name of `sync`. `sync` wins when both are set. Removed in 1.0.0. The default of `sync` is `true`; 0.2.1 defaulted to `false`. |
### `CookieValuesProvider`
Supplies the request's cookie values for server rendering and hydration, so cookie definitions render their stored value
on the server and hydrate without a mismatch. Render it with the same `cookies` on the server and on the client. In
Next.js, render [`PersistentStateProvider`](#persistentstateprovider) instead, which renders this component.
```tsx
import { CookieValuesProvider } from '@nicolopadovan/persistent-state/react'
function CookieValuesProvider(props: CookieValuesProviderProps): ReactElement
```
#### `CookieValuesProviderProps`
| Prop | Type | Default | Description |
| ---------- | ---------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cookies` | `Record \| string` | | **Required.** The request's cookies: a record of cookie names to values URI-decoded once, as Next.js's `cookies()` returns them, or a raw `Cookie` header. |
| `listed` | `readonly string[]` | none | The stored names (`definition.key`) of the cookie definitions the provider covers. When given, only a definition missing from it logs the development warning below, so a covered definition whose cookie the visitor does not have yet stays quiet. `PersistentStateProvider` sets it. |
| `children` | `ReactNode` | none | The tree that renders cookie definitions. |
In development, when a cookie definition is rendered on the server under a provider that holds no cookie for it, a
single warning per key is logged, and the definition renders its default. Without `listed`, "holds no cookie" means that
no cookie name of the key is present in `cookies`.
### `StoredStateMeta`
The third element of the tuple returned by `useStoredState` and `usePersistentState`.
```ts
interface StoredStateMeta {
reset: () => void
isPersistent: boolean
}
```
| Property | Type | Description |
| -------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
| `reset` | `() => void` | Sets the value back to the default and removes the stored item. It keeps its identity across renders. |
| `isPersistent` | `boolean` | Whether the value is being stored. `true` during server rendering and hydration, then the real state. |
## `@nicolopadovan/persistent-state/next`
### `PersistentStateProvider`
A Server Component that makes the stored values of cookie definitions available to server rendering and hydration, so
they render without a flash of their default. It reads only the cookies of the listed definitions, under every name an
older version may have written them, and renders `CookieValuesProvider` with them. Other cookies never reach the client.
```tsx
import { PersistentStateProvider } from '@nicolopadovan/persistent-state/next'
async function PersistentStateProvider(props: PersistentStateProviderProps): Promise{children}
```
Like every `cookies()` call from `next/headers`, it makes the route dynamic, so render it in the layout that needs it,
not at the root of an app where every route would become dynamic.
#### `PersistentStateProviderProps`
```ts
interface PersistentStateProviderProps {
states: readonly Pick, 'key' | 'storage'>[]
children?: ReactNode
}
```
| Prop | Type | Default | Description |
| ---------- | ----------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `states` | `readonly PersistentState<…, 'cookie'>[]` | | **Required.** The cookie definitions whose stored values the first render needs. A definition for another storage is a type error. |
| `children` | `ReactNode` | none | The tree that renders those definitions. |
### `readState(definition)`
Reads a cookie definition in a Server Component, Server Action or Route Handler. It is
`readCookieState(definition, await cookies())`.
```tsx
import { readState } from '@nicolopadovan/persistent-state/next'
async function readState(definition: PersistentState): Promise
const theme = await readState(themeState)
```
| Parameter | Type | Description |
| ------------ | ------------------------------ | ----------------------------------------------------------------------------------- |
| `definition` | `PersistentState` | **Required.** A definition created by `persistentState()` with `storage: 'cookie'`. |
**Returns** a promise of the stored value, decoded, migrated and validated as in the browser, or the definition's
default when the visitor has no cookie for it. It makes the route dynamic.
**Throws** a `TypeError` when `definition` was not created by `persistentState()`. Passing a definition for another
storage is a type error.
# Getting started
> Install persistent-state, declare a persisted key once, and use it in React and in a Next.js App Router application.
import { Tabs, TabItem } from '@astrojs/starlight/components'
This page lists what persistent-state needs, installs it, and takes you from declaring a key to using it in React and in
a Next.js App Router application.
:::caution[0.x releases may break]
persistent-state is in its 0.x series. Every 0.x minor release may contain breaking changes. Pin the minor version
you depend on and read the [migration page](/help/migrating/) before you upgrade.
:::
## System requirements
| Environment | Requirement |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Node.js | 22 or later, for building and for server rendering. The core imports nothing from React or Node.js, so `readCookieState` also runs in edge runtimes. |
| Browsers | Chrome and Edge 94, Firefox 93, Safari and iOS Safari 15.4 or later. CI checks the package's code and syntax against these versions. Your bundler transpiles it if you target older browsers. |
| React | 18.3 or 19, only for `@nicolopadovan/persistent-state/react` and `@nicolopadovan/persistent-state/next`. |
| Next.js | 15 or 16, only for `@nicolopadovan/persistent-state/next`, in the App Router. |
| TypeScript | 5.4 or later, because the public types use `NoInfer`. The entry points are resolved through the `exports` field, so `moduleResolution` must be `bundler`, `node16` or `nodenext`. Projects created with `create-next-app` already use `bundler`. |
## Install the package
```bash
pnpm add @nicolopadovan/persistent-state
```
```bash
npm install @nicolopadovan/persistent-state
```
```bash
yarn add @nicolopadovan/persistent-state
```
```bash
bun add @nicolopadovan/persistent-state
```
persistent-state has no runtime dependencies. `react` and `next` are optional peer dependencies: install them only for
the entry points that use them.
## Entry points
persistent-state is published as ES modules only, with type declarations and source maps. Each entry point is declared
in the package's `exports` field, and the package is marked `"sideEffects": false`, so bundlers include only the
exports you import.
| Entry point | Runs in | Exports |
| --------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------- |
| `@nicolopadovan/persistent-state` | Browser, Node.js, edge | `persistentState`, `refreshConsent`, `readCookieState`, `PersistentStateError` and the types |
| `@nicolopadovan/persistent-state/react` | Browser and React SSR | `useStoredState`, `usePersistentState`, `CookieValuesProvider` |
| `@nicolopadovan/persistent-state/next` | Next.js server (App Router) | `PersistentStateProvider`, `readState` |
`@nicolopadovan/persistent-state/react` starts with a `'use client'` directive, so a Server Component can render
`CookieValuesProvider` without adding your own client boundary; the hooks run only in Client Components.
`@nicolopadovan/persistent-state/next` imports `next/headers`: import it only from Server Components, Server Actions
and Route Handlers. The full list of exports, with signatures, is in the [API reference](/reference/api/).
## Declare a key
Declare each persisted key once, at module scope, and export the result. Put it in a module that both server and
client code can import, so do not add `'use client'` to it.
```ts title="lib/state.ts"
import { persistentState } from '@nicolopadovan/persistent-state'
export const sidebarOpen = persistentState('sidebar-open', { default: true })
```
The first argument is the key under which the value is stored. `default` is required: it is the value used when nothing
valid is stored, on the server, and during hydration. The type of the value is inferred from `default`, so
`sidebarOpen` holds a `boolean`. Without a `storage` option the value lives in `localStorage`.
The object that `persistentState()` returns has `get()`, `set()`, `reset()`, `subscribe()` and `isPersistent()`, and
works outside React:
```ts
sidebarOpen.set(false)
sidebarOpen.set((open) => !open)
sidebarOpen.get() // true
sidebarOpen.reset() // removes the stored item and returns to the default
```
Every option has a default; set only what you need.
| Option | Use it to | Guide |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- |
| `storage` | Choose `'cookie'`, `'localStorage'` or `'sessionStorage'`, and set the cookie attributes. | [Choosing a storage](/guides/choosing-a-storage/) |
| `schema`, `version`, `migrate` | Validate stored values and upgrade values written by older code. | [Schemas and migrations](/guides/schemas-and-migrations/) |
| `expires` | Limit how long a value is kept. | [Schemas and migrations](/guides/schemas-and-migrations/#expiry) |
| `consent` | Store only while the visitor has allowed it. | [Consent](/guides/consent/) |
| `namespace`, `debounceMs`, `sync`, `serializer`, `onError` | Prefix the stored name, delay writes, follow other tabs, change the stored text, and receive failures. | [API reference](/reference/api/) |
## Use it in React
`useStoredState()` takes a definition and returns a tuple, like `useState` with a third element.
```tsx title="components/sidebar-toggle.tsx"
'use client'
import { useStoredState } from '@nicolopadovan/persistent-state/react'
import { sidebarOpen } from '@/lib/state'
export function SidebarToggle() {
const [open, setOpen] = useStoredState(sidebarOpen)
return (
)
}
```
- `setOpen` accepts a value or an updater function, and keeps its identity across renders.
- Every component that uses the same definition shares one value and re-renders together. Other tabs follow changes by
default.
- The third element, `{ reset, isPersistent }`, removes the stored item and returns to the default, and tells you
whether the value is being stored. `isPersistent` is `false` while the value lives only in memory: when the storage is
blocked or full, or while `consent` is not given.
```tsx
const [open, setOpen, { reset, isPersistent }] = useStoredState(sidebarOpen)
```
Why does a localStorage value appear after the first render?
React compares the HTML the server rendered with the first render on the client. The server never reads Web Storage,
and the client must not read it while hydrating, or the two would differ. Definitions in `localStorage` and
`sessionStorage` therefore render their default during server rendering and hydration, and the stored value follows
right after hydration.
Cookie definitions can avoid that extra render, because a server can read cookies. See
[Choosing a storage](/guides/choosing-a-storage/#server-rendering-and-hydration).
For a key used in one component only, `usePersistentState(key, initial, options)` from the same entry point declares the
key inside the component and returns the same tuple. Prefer a definition for keys used in more than one place or read
on the server.
## Render cookie state on the server in Next.js
A cookie is the one storage the server can read. To render a stored value in the first HTML, declare it with
`storage: 'cookie'`, list it in `PersistentStateProvider`, and use it with `useStoredState()`.
```ts title="lib/state.ts"
import { persistentState } from '@nicolopadovan/persistent-state'
export type Theme = 'light' | 'dark' | 'system'
export const theme = persistentState('theme', { default: 'system', storage: 'cookie' })
```
```tsx title="app/layout.tsx"
import type { ReactNode } from 'react'
import { PersistentStateProvider } from '@nicolopadovan/persistent-state/next'
import { theme } from '@/lib/state'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
{children}
)
}
```
```tsx title="components/theme-toggle.tsx"
'use client'
import { useStoredState } from '@nicolopadovan/persistent-state/react'
import { theme } from '@/lib/state'
export function ThemeToggle() {
const [value, setValue] = useStoredState(theme)
return (
)
}
```
The server renders `ThemeToggle` with the theme in the visitor's cookie, and the client hydrates with the same value, so
the page never shows the default first. `PersistentStateProvider` reads cookies, so it makes the routes below it
dynamic: render it in the layout that needs it. The [Next.js guide](/guides/nextjs/) covers this, reading cookie state in
Server Components, and what is not possible yet.
## Next steps
- [Choosing a storage](/guides/choosing-a-storage/): cookie, `localStorage` or `sessionStorage`.
- [Next.js App Router](/guides/nextjs/): render stored state on the server without a flash.
- [Schemas and migrations](/guides/schemas-and-migrations/): validate stored values and change their shape safely.
- [Consent](/guides/consent/): store only while the visitor has allowed it.