npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pyreon/storage

v0.23.0

Published

Reactive client-side storage for Pyreon — localStorage, sessionStorage, cookies, IndexedDB

Readme

@pyreon/storage

Reactive client-side storage — localStorage, sessionStorage, cookies, IndexedDB, custom backends.

@pyreon/storage exposes five reactive primitives backed by @pyreon/reactivity signals: useStorage (localStorage, cross-tab synced), useSessionStorage (tab-scoped), useCookie (SSR-readable, configurable expiry), useIndexedDB (large data, debounced writes), and useMemoryStorage (SSR/testing). All return StorageSignal<T> — extends Signal<T> with .remove(). Same key returns the same instance — two useStorage('theme', 'light') calls in different components share the SAME signal, so writes propagate instantly without registry wiring. A createStorage(backend) factory lets you bring your own backend (encrypted, remote, IPC, …).

Install

bun add @pyreon/storage @pyreon/reactivity

@pyreon/reactivity is the only runtime dep — @pyreon/core is NOT required (these are framework-agnostic primitives).

Quick start

import {
  useStorage,
  useSessionStorage,
  useCookie,
  useIndexedDB,
  useMemoryStorage,
} from '@pyreon/storage'

// localStorage — persistent, cross-tab synced via the native `storage` event
const theme = useStorage('theme', 'light')
theme() // 'light' — read reactively
theme.set('dark') // updates signal + localStorage + any other tab
theme.remove() // resets to default and removes from storage

// sessionStorage — tab-scoped
const wizardStep = useSessionStorage('wizard-step', 0)

// Cookie — SSR-readable, configurable expiry
const locale = useCookie('locale', 'en', { maxAge: 365 * 86400 })

// IndexedDB — large data, debounced writes (default 100ms)
const draft = useIndexedDB('article-draft', { title: '', body: '' })

// Memory — SSR / testing — no persistence
const sessionId = useMemoryStorage('session-id', '')

Hooks

All five hooks share the same signature: useX(key, defaultValue, options?)StorageSignal<T>.

| Hook | Backend | Cross-tab | SSR-safe | Notes | | ------------------------------------------- | --------------- | --------- | -------- | ------------------------------------ | | useStorage(key, default, options?) | localStorage | ✅ yes | safe | Auto-syncs via storage event | | useSessionStorage(key, default, options?) | sessionStorage | ❌ no | safe | Tab-scoped | | useCookie(key, default, options?) | document.cookie | ❌ no | ✅ yes | Reads server cookie via setCookieSource | | useIndexedDB(key, default, options?) | IndexedDB | ❌ no | safe | Async; debounced writes | | useMemoryStorage(key, default) | in-memory Map | ❌ no | safe | SSR / testing |

Same key returns the SAME signal instance per backend:

const a = useStorage('theme', 'light')
const b = useStorage('theme', 'light')
a === b // true — registered once per (backend, key) pair

StorageSignal<T> interface

interface StorageSignal<T> extends Signal<T> {
  remove(): void // clear storage, reset to default value
}

Inherits Signal<T>: () (read), .set(v) (write), .update(fn), .peek(), .subscribe(fn), .direct(fn).

Options (shared)

| Option | Type | Description | | ---------------- | ----------------------------- | -------------------------------------------------------- | | serializer? | (value: T) => string | Default: JSON.stringify | | deserializer? | (raw: string) => T | Default: JSON.parse | | onError? | (error: Error) => T \| void | Called on deserialization fail; return fallback or void |

useCookie options

Extends StorageOptions<T> with:

| Option | Type | Default | | ------------ | --------------------------------- | ------- | | maxAge? | number (seconds) | — | | expires? | Date | — | | path? | string | '/' | | domain? | string | — | | secure? | boolean | false | | sameSite? | 'strict' \| 'lax' \| 'none' | 'lax' |

useIndexedDB options

Extends StorageOptions<T> with:

| Option | Type | Default | | ------------ | -------- | ------------------ | | dbName? | string | 'pyreon-storage' | | storeName? | string | 'kv' | | debounceMs?| number | 100 |

IndexedDB writes are debounced — rapid .set() calls coalesce to a single transaction. The signal updates synchronously; persistence is async.

setCookieSource(header) — SSR

Cookies on the server come from the request header, not document.cookie. Call once per request:

import { setCookieSource, useCookie } from '@pyreon/storage'

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

// Then anywhere downstream:
const locale = useCookie('locale', 'en') // reads from the request cookie on the server

Custom backends — createStorage

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', '')

createStorage(backend, backendName?) returns a hook of the same shape as useStorage. The backendName argument scopes the same-key-same-signal registry per backend — two createStorage calls produce hooks with isolated registries.

StorageBackend (sync) and AsyncStorageBackend (used internally by IndexedDB) are both exported types if you need to type a custom backend manually.

Cleanup utilities

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

removeStorage('theme') // localStorage by default
removeStorage('step', { type: 'session' })
removeStorage('locale', { type: 'cookie' })

clearStorage('local') // clear all managed localStorage entries
clearStorage('all') // clear every backend

removeStorage calls .remove() on the registered signal (resetting it to default + clearing storage). clearStorage walks the registry for that backend and removes every entry. Unmanaged keys in raw storage are NOT touched.

Internal _v getter contract

Every storage signal wraps a base signal() with a callable that forwards _v via getter — required by the compiler-emitted _bindText / _bindDirect fast paths, which read source._v directly (skipping the function call) for cached signals. Without the getter forwarding, every binding writes '' on initial render and on every subscriber notification. This contract is enforced by the pyreon/storage-signal-v-forwarding lint rule — any custom wrapper-signal package built on @pyreon/reactivity MUST forward _v. The shared wrapBaseSignal() helper inside this package is the canonical shape; see packages/fundamentals/storage/src/wrap-base-signal.ts.

Types

| Type | Description | | --------------------- | ------------------------------------------------------ | | StorageSignal<T> | Signal<T> + .remove() | | StorageOptions<T> | Shared options — serializer, deserializer, onError | | CookieOptions<T> | Cookie-specific options (extends StorageOptions) | | IndexedDBOptions<T> | IndexedDB-specific options (extends StorageOptions) | | StorageBackend | { get(key), set(key, value), remove(key) } (sync) | | AsyncStorageBackend | Async variant — internal IndexedDB shape |

Gotchas

  • Same key returns the SAME signal instance per backend — two useStorage('theme', 'light') calls in different components share state. This is by design; do NOT expect a fresh signal per call.
  • useStorage cross-tab listener is ref-counted — attached on first useStorage, removed when the last signal disposes via .remove(). Pre-fix the listener leaked across the page lifetime.
  • The internal _v getter is load-bearing — if you write a custom wrapper-signal on top of @pyreon/reactivity and forget to forward _v, the compiler-emitted fast path binds to undefined and renders empty (the bug class PR #546 fixed). Enforced by the pyreon/storage-signal-v-forwarding lint rule. The package's wrapBaseSignal() helper handles this — use it for any new backend.
  • Cookies need setCookieSource(header) on the serverdocument.cookie doesn't exist in SSR. Without it, useCookie returns the default value during render.
  • IndexedDB writes are debounced (default 100ms) — the signal updates immediately; persistence trails. On unload, in-flight writes complete (most browsers honor pending IndexedDB transactions).
  • useMemoryStorage doesn't persist — values clear on reload. Useful for SSR, tests, and request-scoped state that should NOT survive navigation.
  • Don't write via call-shorthand (storageSignal(newValue)) — that's a read with discarded arg. Use .set(value). Caught by the opt-in lint rule pyreon/no-storage-write-as-call (auto-fixable).
  • onError doesn't fire for IndexedDB read failures — they fall back to the default silently. Use try/catch around useIndexedDB if you need explicit error handling.

Documentation

Full docs: docs.pyreon.dev/docs/storage (or docs/docs/storage.md in this repo).

License

MIT