pyreon

@pyreon/storage is signal-backed persistence. Every stored value is a reactive signal — read it like a signal, write it with .set(), and the underlying storage backend persists automatically. There is no separate "load / save" step, no useEffect to sync, no manual JSON.parse. A useStorage('theme', 'light') value participates in the exact same fine-grained reactivity as a plain signal(): it patches the DOM in place, drives computed() derivations, and re-runs effect()s.

@pyreon/storagestable

Five backends share one API shape, so you can swap persistence strategy by changing the hook name:

HookBackendPersists acrossCross-tab syncSSR-readable
useStoragelocalStoragereloads + tabs✅ yesno (default)
useSessionStoragesessionStoragereloads (per-tab)❌ nono (default)
useCookiecookiesconfigurable expiry❌ no✅ via setCookieSource
useIndexedDBIndexedDBreloads + tabs (large data)❌ nono (default)
useMemoryStoragein-memory Mapnothing (ephemeral)❌ no✅ safe fallback

Plus createStorage(backend) for custom backends (encrypted, remote, etc.) — it produces a hook with the identical signature.

Installation

npm install @pyreon/storage
bun add @pyreon/storage
pnpm add @pyreon/storage
yarn add @pyreon/storage

Peer dependency: @pyreon/reactivity.

Quick Start

import { useStorage } from '@pyreon/storage'

const theme = useStorage('theme', 'light')

theme() // 'light' — or the stored value if one exists
theme.set('dark') // updates the signal AND writes to localStorage
theme.remove() // deletes the key, resets the signal to 'light'

Because the return value is a signal, it works everywhere a signal works — JSX, effects, computeds, stores:

function ThemeToggle() {
  const theme = useStorage('theme', 'light')
  return (
    <button onClick={() => theme.set(theme() === 'dark' ? 'light' : 'dark')}>
      {theme()} {/* patches in place when theme changes */}
    </button>
  )
}
Reactive Storage

The StorageSignal<T> shape

Every hook returns a StorageSignal<T> — a full Signal<T> plus one extra method:

interface StorageSignal<T> extends Signal<T> {
  /** Remove the value from storage and reset the signal to the default value. */
  remove(): void
}

So you get the complete signal surface — call it to read (theme()), .set(next), .update(fn), .peek(), .subscribe(fn), .direct(fn) — and .remove() on top.

const count = useStorage('count', 0)

count() //                  read (reactive)
count.set(5) //             write + persist
count.update((n) => n + 1) // write + persist (functional)
count.peek() //             read WITHOUT subscribing
count.remove() //           delete from storage, reset to 0

Internally each StorageSignal is built with wrapSignal from @pyreon/reactivity: reads (including the .direct() channel and the internal _v field the compiler's _bindText fast path reads) delegate to a shared base signal(), while .set routes through the backend's persist function. This is what lets <strong>{theme()}</strong> patch in place on theme.set(…) with no re-render — full participation in the reactivity system.

Backends

useStorage() — localStorage

Persistent across reloads and synced across tabs. Change a value in one tab and every other tab's signal updates instantly via the native storage event.

import { useStorage } from '@pyreon/storage'
import { effect } from '@pyreon/reactivity'

// Primitives
const theme = useStorage('theme', 'light')
const sidebarOpen = useStorage('sidebar-open', true)

// Objects — JSON-serialized automatically
const prefs = useStorage('prefs', { density: 'comfortable', lang: 'en' })

// Read reactively — works in effects, computeds, JSX
effect(() => {
  document.body.className = theme() === 'dark' ? 'dark-mode' : ''
})

// Cross-tab: another tab calling theme.set('dark') updates THIS signal too.

Same key → same signal

useStorage('theme', …) called from two different components returns the same signal instance — there is one signal per (backend, key) pair, refcounted so it survives until the last consumer removes it:

// Header.tsx
const theme = useStorage('theme', 'light')

// Settings.tsx — identical object, no drift
const theme = useStorage('theme', 'light')

This means a .remove() in one component does not orphan the others from cross-tab updates: the registry entry (and the window storage listener) is only torn down on the last consumer's .remove(). Until then, surviving consumers keep receiving cross-tab events.

Write coalescing — writeDebounceMs

localStorage.setItem is synchronous main-thread I/O. For a value persisted on every keystroke (a draft), that is a JSON.stringify + setItem per character. Pass writeDebounceMs to coalesce the write — the signal still updates synchronously (the UI stays reactive), only the persist is debounced. The latest value wins, and a pending write is flushed on pagehide / beforeunload so the last value is never lost on tab close.

// Signal is reactive immediately; the localStorage write is coalesced to 300ms.
const draft = useStorage('post-draft', '', { writeDebounceMs: 300 })
draft.set(e.target.value) // sync signal update, debounced persist

writeDebounceMs is opt-in — omit it (or 0) for the default synchronous write. It applies to useStorage and useSessionStorage only; cookie / IndexedDB (already async-debounced) / memory backends ignore it. A single shared pagehide/beforeunload listener flushes all pending writes (not one listener per signal — avoiding listener pile-up), and .remove() cancels any pending write so a removed key can't be resurrected by a stale timer.

useSessionStorage() — per-tab

Same shape as useStorage, but writes go to sessionStorage: scoped to the current tab and cleared when the tab closes. No cross-tab syncsessionStorage is per-tab by spec, and browsers do not fire storage events for it.

import { useSessionStorage } from '@pyreon/storage'

const wizardStep = useSessionStorage('wizard-step', 0)
const formDraft = useSessionStorage('contact-draft', { name: '', message: '' })

Use it for per-visit state that should not survive a tab close: multi-step wizard progress, an unsaved form draft, a scroll position, a "you have unsaved changes" flag.

useCookie() — server-readable

Backed by browser cookies, with configurable expiry, path, domain, secure, and SameSite. Cookies are the only backend readable during SSR.

import { useCookie } from '@pyreon/storage'

const locale = useCookie('locale', 'en', {
  maxAge: 60 * 60 * 24 * 365, // 1 year, in SECONDS
  path: '/',
  sameSite: 'lax',
})

const consent = useCookie('cookie-consent', false, {
  maxAge: 60 * 60 * 24 * 180, // 6 months
})

locale.set('de') // writes document.cookie + updates signal
locale.remove() // deletes the cookie, resets to 'en'

CookieOptions<T> extends StorageOptions<T>, so it also accepts serializer / deserializer / onError (see Serialization). Cookie-specific options:

OptionTypeDefaultDescription
maxAgenumberMax age in seconds (HTTP spec). 86400 = one day.
expiresDateAbsolute expiry date (alternative to maxAge).
pathstring'/'Cookie path scope.
domainstringCookie domain scope.
securebooleanfalseSend only over HTTPS.
sameSite'strict' | 'lax' | 'none''lax'SameSite policy (emitted lowercased).

SSR support — setCookieSource()

On the server there is no document.cookie. Tell useCookie where to read by passing the request's raw Cookie header to setCookieSource() at the top of your request handler — then SSR renders with the user's actual cookie state instead of the default.

import { setCookieSource, useCookie } from '@pyreon/storage'
import { renderToString } from '@pyreon/runtime-server'

// In your SSR request handler:
setCookieSource(request.headers.get('cookie') ?? '')

// Now useCookie reads from the request header on the server.
const html = await renderToString(<App />)

In the browser useCookie reads document.cookie directly; the source set by setCookieSource is only consulted on the server.

setCookieSource also accepts an accessor () => string (evaluated lazily at each cookie read) or null to clear. The accessor is the concurrency-safe form — see the warning below.

// Concurrency-safe: read the current request's cookies out of your per-request
// context (e.g. the AsyncLocalStorage that `runWithRequestContext` maintains).
setCookieSource(() => currentRequest().headers.get('cookie') ?? '')

useIndexedDB() — large data

For data that exceeds the ~5MB localStorage budget (offline drafts, cached datasets, structured blobs). Writes are debounced; the value hydrates asynchronously.

import { useIndexedDB } from '@pyreon/storage'

const draft = useIndexedDB('article-draft', { title: '', body: '' })

// Signal updates immediately; the IDB write is debounced (default 100ms).
draft.set({ title: 'My Post', body: '...10KB of content...' })

IndexedDB options

IndexedDBOptions<T> extends StorageOptions<T> (so serializer / deserializer / onError apply) plus:

OptionTypeDefaultDescription
dbNamestring'pyreon-storage'IndexedDB database name.
storeNamestring'kv'Object store name within the DB.
debounceMsnumber100Debounce window for the async write.

Note useIndexedDB uses its own debounceMs option (not writeDebounceMs) — IDB writes are always async-debounced.

useMemoryStorage() — ephemeral / SSR-safe

An in-memory signal that mimics the storage-hook shape (.remove() and all). It is backed by a module-level Map, so values are lost on reload — there is no persistence. Use it as an SSR-safe fallback or in environments without localStorage/sessionStorage (sandboxed iframes, web workers without DOM, some embedded WebViews).

import { useMemoryStorage } from '@pyreon/storage'

const temp = useMemoryStorage('draft-id-42', '')
temp.set('typing...') // reactive, but gone on reload

It accepts the same (key, defaultValue, options?) signature as the other hooks (it is literally createStorage(memoryBackend)), so serializer / deserializer / onError work — though for an in-memory store you rarely need them.

createStorage() — custom backends

createStorage(backend) returns a hook with the same signature as useStorage, backed by any persistence layer you supply. Use it for encrypted storage, a remote API, or any custom adapter.

The backend object implements three methods — get / set / remove (not getItem/setItem/removeItem):

interface StorageBackend {
  /** Read a raw string value. Return null if the key is absent. */
  get(key: string): string | null
  /** Write a raw string value. */
  set(key: string, value: string): void
  /** Remove a key. */
  remove(key: string): void
}
import { createStorage } from '@pyreon/storage'

const useEncryptedStorage = createStorage({
  get: (key) => decrypt(localStorage.getItem(key)),
  set: (key, value) => localStorage.setItem(key, encrypt(value)),
  remove: (key) => localStorage.removeItem(key),
})

const secret = useEncryptedStorage('api-key', '')
secret.set('sk-…') // encrypted before it touches localStorage

The backend receives the already-serialized string (set is called with serialize(value)), and its get return is passed to deserialize. So your backend deals only in strings — serialization/deserialization stays the hook's job (override it via serializer/deserializer if needed).

An optional second argument names the backend for registry isolation (createStorage(backend, 'my-backend')); it defaults to 'custom'.

Serialization

By default values are serialized with JSON.stringify and read back with JSON.parse. Anything JSON-representable (primitives, plain objects, arrays) round-trips automatically. For non-JSON types, supply serializer / deserializer (note: serializer/deserializer, not serialize/deserialize):

// Store a Date
const lastVisit = useStorage('last-visit', new Date(), {
  serializer: (d) => d.toISOString(),
  deserializer: (s) => new Date(s),
})

// Store a Set
const favorites = useStorage('favorites', new Set<string>(), {
  serializer: (s) => JSON.stringify([...s]),
  deserializer: (s) => new Set(JSON.parse(s)),
})

// Store a Map
const cache = useStorage('cache', new Map<string, number>(), {
  serializer: (m) => JSON.stringify([...m]),
  deserializer: (s) => new Map(JSON.parse(s)),
})

StorageOptions<T>

Shared by every hook (and extended by CookieOptions / IndexedDBOptions):

OptionTypeDefaultDescription
serializer(value: T) => stringJSON.stringifySerialize a value to a string before persisting.
deserializer(raw: string) => TJSON.parseParse a stored string back to a value.
onError(error: Error) => T | undefinedCalled on a deserialize failure (return a fallback / void) OR a WRITE failure (quota / blocked — return ignored, notification).
versionnumberPersisted-schema version — wraps the value so a later load can migrate it.
migrate(persisted: unknown, from: number) => TTransform a value stored under an older version (pre-versioning value is from = 0).
writeDebounceMsnumber0Coalesce the persist write (localStorage / sessionStorage only).

Versioned migration — version + migrate

When the shape of a stored value changes between releases, bump version and provide migrate so users with the old shape on disk upgrade cleanly instead of loading a mismatched object:

// v1 shipped { name: string }; v2 splits it into first / last:
const profile = useStorage(
  'profile',
  { first: '', last: '' },
  {
    version: 2,
    migrate: (old, from) => {
      // `from` is the version the value was stored under. A value written
      // BEFORE `version` existed (no envelope) is migrated as `from = 0`.
      if (from < 2 && old && typeof old === 'object' && 'name' in old) {
        const [first = '', last = ''] = String((old as { name: string }).name).split(' ')
        return { first, last }
      }
      return old as { first: string; last: string }
    },
  },
)

version stores the value inside a small JSON envelope carrying the version. On read, a version mismatch runs migrate (its result is used as-is — NOT re-run through deserializer). Works on every backend, and the migration is applied on cross-tab sync too: a tab holding the old shape upgrades when a newer tab writes.

Error handling

A corrupt or unparseable stored value will not crash your app. Deserialization is wrapped in a try/catch: on failure it falls back to the defaultValue, or to whatever onError returns. onError also fires on a WRITE failure (a setItem that throws — quota exceeded, blocked storage): the in-memory signal has already updated, so the return value is ignored — it's a notification so you can surface the quota error to the user.

const value = useStorage('key', 'fallback', {
  onError: (error) => {
    console.warn('Storage read failed:', error)
    return 'custom-fallback' // or return undefined to use the default
  },
})

Write failures (quota exceeded, private-browsing restrictions, a throwing custom backend) are also swallowed for the synchronous backends — the in-memory signal still updates, so the UI stays consistent even though the persist failed. getWebStorage additionally probes localStorage/sessionStorage for write access on first use and degrades to a no-op if it is blocked (private mode), so useStorage never throws on construction.

Cleanup utilities

Two top-level helpers remove managed keys without holding the signal:

import { removeStorage, clearStorage } from '@pyreon/storage'

removeStorage(key, options?)

Removes one key and resets its signal to the default. The backend is chosen with options.type (default 'local'):

removeStorage('theme') //                       localStorage
removeStorage('wizard-step', { type: 'session' }) // sessionStorage
removeStorage('locale', { type: 'cookie' }) //       cookie
removeStorage('draft', { type: 'indexeddb' }) //     IndexedDB

If a key was never registered through a hook, removeStorage still attempts to clear the raw underlying storage (localStorage / sessionStorage removeItem, or a cookie-delete write) as a best effort.

clearStorage(type?)

Removes all keys managed by @pyreon/storage for a backend. type is positional (default 'local'), with 'all' covering every backend:

clearStorage() //          all managed localStorage entries
clearStorage('session') // all managed sessionStorage entries
clearStorage('cookie') //  all managed cookies
clearStorage('indexeddb') // all managed IndexedDB entries
clearStorage('all') //     everything

SSR & hydration

@pyreon/storage is SSR-safe in both string-rendered (renderToString) and hydrated apps.

On the server, the browser-only backends — useStorage, useSessionStorage, useIndexedDB, useMemoryStorage — have no server equivalent, so isBrowser() returns false, the read path bails, and the signal initializes to the defaultValue you passed. The rendered HTML reflects the default:

const theme = useStorage('theme', 'light')
// Server: theme() → 'light' (no localStorage on the server)
// SSR HTML: <strong>light</strong>

useCookie is the exception — it reads from the request via setCookieSource(request.headers.get('cookie')), so the SSR markup reflects the client's actual cookie state.

On client hydration, browser-backed signals re-read the real stored value and patch the DOM if it differs from the SSR default. A user with localStorage.theme = 'dark' may see a brief light → dark flash. For preferences that change rendering critically (theme, language), mirror the choice into a cookie so SSR reads the right value on first paint — no flash:

// Companion cookie writes alongside the localStorage signal.
const themeCookie = useCookie('theme', 'light', { maxAge: 60 * 60 * 24 * 365 })
const theme = useStorage('theme', themeCookie())
// SSR reads the cookie (via setCookieSource) → renders the right theme on first paint.
// Client hydrates with the matching localStorage value → no flash.

Reactive bindings through storage signals ride the compiler's _bindText fast path, same as base signal() / computed()<strong>{theme()}</strong> patches the text node in place when theme.set(…) fires; no re-render, no re-mount.

Cross-tab sync

Only useStorage (localStorage) syncs across tabs. The package attaches a single window storage listener (refcounted, detached when the last localStorage signal is removed); when another tab writes a managed key, the listener deserializes the new value and .sets the local signal — propagating into every effect, computed, and bound DOM node in this tab automatically.

const cart = useStorage('cart', [] as Item[])
// Tab A: cart.set([...cart(), item])
// Tab B: cart() updates instantly — no polling, no manual wiring.

The other backends do not notify across tabs: sessionStorage is per-tab by spec, and cookies / IndexedDB fire no cross-tab event. If you need multi-tab consistency on those, coordinate with a BroadcastChannel yourself.

Real-world patterns

Persisted theme with no SSR flash

// Cookie is the source of truth for SSR; localStorage mirrors it for fast client reads.
const themeCookie = useCookie('theme', 'light', { maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' })
const theme = useStorage('theme', themeCookie())

function setTheme(next: 'light' | 'dark') {
  theme.set(next) // localStorage + cross-tab
  themeCookie.set(next) // cookie for next SSR
}

Draft persisted on every keystroke

function PostEditor() {
  // Reactive immediately; localStorage write debounced 500ms; flushed on tab close.
  const draft = useStorage('post-draft', '', { writeDebounceMs: 500 })
  return <textarea value={draft()} onInput={(e) => draft.set(e.currentTarget.value)} />
}

Encrypted secret via a custom backend

const useSecure = createStorage({
  get: (key) => {
    const raw = localStorage.getItem(key)
    return raw === null ? null : decrypt(raw)
  },
  set: (key, value) => localStorage.setItem(key, encrypt(value)),
  remove: (key) => localStorage.removeItem(key),
})

const apiKey = useSecure('api-key', '')
apiKey.set('sk-live-…') // encrypted at rest

API Reference

Hooks

ExportSignatureReturns
useStorage(key, default, options?)<T>(key: string, defaultValue: T, options?: StorageOptions<T>) => StorageSignal<T>localStorage signal, cross-tab synced
useSessionStorage(key, default, options?)<T>(key: string, defaultValue: T, options?: StorageOptions<T>) => StorageSignal<T>sessionStorage signal, per-tab
useCookie(key, default, options?)<T>(key: string, defaultValue: T, options?: CookieOptions<T>) => StorageSignal<T>cookie signal, SSR-readable
useIndexedDB(key, default, options?)<T>(key: string, defaultValue: T, options?: IndexedDBOptions<T>) => StorageSignal<T>IndexedDB signal, async-hydrated
useMemoryStorage(key, default, options?)<T>(key: string, defaultValue: T, options?: StorageOptions<T>) => StorageSignal<T>in-memory signal, ephemeral

Factory & SSR

ExportSignatureDescription
createStorage(backend, name?)(backend: StorageBackend, backendName?: string) => <T>(key, default, options?) => StorageSignal<T>Build a custom-backend hook with the useStorage shape.
setCookieSource(cookieHeader)(cookieHeader: string) => voidTell useCookie the raw request Cookie header for SSR.

Utilities

ExportSignatureDescription
removeStorage(key, options?)(key: string, options?: { type?: 'local' | 'session' | 'cookie' | 'indexeddb' }) => voidRemove one key (default backend 'local') + reset its signal.
clearStorage(type?)(type?: 'local' | 'session' | 'cookie' | 'indexeddb' | 'all') => voidRemove all managed keys for a backend (default 'local').

StorageSignal<T> instance

MemberReturnsDescription
signal()TRead the value (reactive in effects / computeds / JSX).
signal.set(v)voidWrite the value to the signal + persist to the backend.
signal.update(fn)voidFunctional write — set(fn(peek())) + persist.
signal.peek()TRead without subscribing.
signal.subscribe(fn)() => voidSubscribe to changes; returns an unsubscribe.
signal.direct(fn)() => voidLow-level direct subscription channel.
signal.remove()voidDelete from storage and reset the signal to its default.

Types

TypeShape
StorageSignal<T>Signal<T> extended with remove(): void.
StorageOptions<T>{ serializer?, deserializer?, onError?, writeDebounceMs? }.
CookieOptions<T>StorageOptions<T> + { maxAge?, expires?, path?, domain?, secure?, sameSite? }.
IndexedDBOptions<T>StorageOptions<T> + { dbName?, storeName?, debounceMs? }.
StorageBackend{ get(key): string | null; set(key, value): void; remove(key): void } (synchronous).
AsyncStorageBackend{ get(key): Promise<string | null>; set(key, value): Promise<void>; remove(key): Promise<void> }.
Storage