{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreidkdpinaseyrq5wxudkjydpwwtsuk4l3ms3i7rhlguzdvar62ovve",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mp2bdaum5vc2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreienfxn2rl2s5hfocaz3jk7qql7scmexbhcimap4qklx5l7xobbvz4"
},
"mimeType": "image/webp",
"size": 85522
},
"path": "/kidjoker/stokado-a-zero-dependency-proxy-wrapper-that-makes-browser-storage-feel-like-a-plain-object-3i0",
"publishedAt": "2026-06-24T15:30:39.000Z",
"site": "https://dev.to",
"tags": [
"webdev",
"javascript",
"opensource",
"showdev",
"Stokado",
"localForage",
"lz-string",
"useStorage from @vueuse/core",
"idb-keyval",
"github.com/KID-joker/stokado",
"@react-native-async-storage"
],
"textContent": "If you've shipped anything to the browser, you've used `localStorage`. And if you've used it for more than five minutes, you've also written this exact line more times than you'd like to admit:\n\n\n\n const user = JSON.parse(localStorage.getItem('user') || 'null')\n\n\nThe Web Storage API has aged remarkably well for something so small, but it carries three persistent pain points that every frontend codebase ends up papering over by hand.\n\n**Pain point #1: everything is a string.** `localStorage.setItem('count', 0)` doesn't store the number `0` — it stores the string `\"0\"`. Read it back and `typeof` is `\"string\"`. Booleans become `\"true\"`/`\"false\"`, `Date` objects collapse into ISO strings (if you're lucky) or `\"[object Object]\"` (if you're not), and `undefined` becomes the literal string `\"undefined\"`. So every project grows a thin serialization layer of `JSON.parse`/`JSON.stringify` wrappers, plus a pile of defensive try/catch blocks for the day a malformed value sneaks in.\n\n**Pain point #2: the API is verbose and stringly-typed.** `getItem`, `setItem`, `removeItem` — three method calls and a string key for what is conceptually just reading and writing a property. It reads nothing like the rest of your code.\n\n**Pain point #3: reactivity is broken in the tab you actually care about.** The native `storage` event only fires in _other_ tabs of the same origin. The tab that performed the write never hears about it. So if you want to react to your own storage changes — the overwhelmingly common case — the platform gives you nothing.\n\nStokado is a small, zero-dependency library that addresses all three by wrapping any storage object in a `Proxy`. It's framework-agnostic, TypeScript-friendly, and works equally well with `localStorage`, `sessionStorage`, cookies, async backends like localForage, and a handful of mini-program runtimes. This article walks through what it actually does, feature by feature, with runnable code.\n\n## Quick start\n\n\n npm install stokado\n\n\n\n import { createProxyStorage } from 'stokado'\n\n const storage = createProxyStorage(localStorage)\n\n // Write like a plain object\n storage.token = 'abc'\n\n // Read like a plain object\n console.log(storage.token) // 'abc'\n\n // Delete with the delete operator\n delete storage.token\n\n\n`createProxyStorage` takes any storage-like object and returns a proxy that behaves like a plain object on the surface, while doing serialization, change notification, expiration, and quota bookkeeping underneath. That's the whole mental model. Everything below is a refinement of it.\n\n## Type-safe serialization\n\nThe single most repetitive chore with native storage is converting values to and from strings. Stokado handles it transparently — and crucially, it preserves the _type_ , not just the shape.\n\n\n\n import { createProxyStorage } from 'stokado'\n\n const storage = createProxyStorage(localStorage)\n\n storage.count = 0\n storage.enabled = false\n storage.missing = null\n storage.createdAt = new Date('2024-01-01T00:00:00Z')\n storage.pattern = /^\\d+$/g\n storage.profile = { name: 'Ada', roles: ['admin'] }\n\n console.log(typeof storage.count) // 'number' (not \"0\")\n console.log(storage.enabled === false) // true (not \"false\")\n console.log(storage.missing) // null (not \"null\")\n console.log(storage.createdAt instanceof Date) // true\n console.log(storage.pattern.test('42')) // true\n console.log(storage.profile.roles[0]) // 'admin'\n\n\nThe trap that catches naive `JSON.parse` wrappers is the falsy-but-valid value: `storage.count = 0` round-trips as the number `0`, not the string `\"0\"` and not `undefined`. The same holds for `false` and `null`. Stokado records the original type tag at write time and reconstructs the matching JavaScript value at read time.\n\nThe supported type set is deliberately broad. In addition to the obvious `string`, `number`, `boolean`, `null`, `undefined`, `object`, and `array`, stokado round-trips `BigInt`, `Date`, `RegExp`, `URL`, `Set`, `Map`, and even `Function`:\n\n\n\n storage.id = 9007199254740993n\n storage.tags = new Set(['a', 'b', 'a'])\n storage.lookup = new Map([['x', 1]])\n storage.api = new URL('https://example.com/v1')\n storage.greet = function (name) {\n return `hi ${name}`\n }\n\n console.log(typeof storage.id) // 'bigint'\n console.log(storage.tags instanceof Set) // true\n console.log(storage.lookup.get('x')) // 1\n console.log(storage.api.host) // 'example.com'\n console.log(storage.greet('Ada')) // 'hi Ada'\n\n\nA note on `Function`: stokado serializes it via `toString()` and rebuilds it with the `Function` constructor on read. That's genuinely useful for storing small configuration callbacks, but be aware it relies on dynamic evaluation and loses closure scope — treat it as a convenience for trusted, self-contained functions rather than a general serialization mechanism.\n\nIf you ever need the raw conversion outside of a proxy, stokado exports the underlying `encode` and `decode` functions directly:\n\n\n\n import { decode, encode } from 'stokado'\n\n\n## Same-tab reactive subscriptions\n\nThis is where stokado earns its keep. You can subscribe to changes on a specific key, and — unlike the native `storage` event — the callback fires in the _same_ tab that made the change.\n\n\n\n import { createProxyStorage } from 'stokado'\n\n const storage = createProxyStorage(localStorage)\n\n storage.on('token', (newValue, oldValue) => {\n console.log(`token changed: ${oldValue} -> ${newValue}`)\n })\n\n storage.token = 'abc' // logs: token changed: undefined -> abc\n storage.token = 'xyz' // logs: token changed: abc -> xyz\n\n\nEvery listener receives `(newValue, oldValue)`, both already deserialized to their proper types. There are three methods:\n\n * `on(key, fn)` — subscribe to all future changes for `key`.\n * `once(key, fn)` — fire exactly once, then auto-unsubscribe.\n * `off(key?, fn?)` — unsubscribe. Pass a key and function to remove one listener, pass just a key to remove all listeners for that key, or pass nothing to clear everything.\n\n\n\n\n function handler(n, o) {\n console.log('once:', n, o)\n }\n\n storage.once('session', handler)\n storage.session = 'first' // fires once\n storage.session = 'second' // silent — already unsubscribed\n\n storage.on('cart', updateBadge)\n storage.off('cart', updateBadge) // remove a single listener\n\n\nSubscriptions even reach into nested properties using dot paths, which is handy when you store structured objects:\n\n\n\n storage.on('profile.name', (n, o) => console.log(`name: ${o} -> ${n}`))\n\n storage.profile = { name: 'Ada' }\n storage.profile.name = 'Grace' // logs: name: Ada -> Grace\n\n\nBecause this works in the same tab, you can use storage as a lightweight intra-app event bus — for example, keeping a header avatar in sync with a settings page without reaching for a global store.\n\n## Expiration and disposable values\n\nNative storage has no concept of a TTL. Stokado adds two complementary lifetime controls through the third argument of `setItem`.\n\n\n\n import { createProxyStorage } from 'stokado'\n\n const storage = createProxyStorage(localStorage)\n\n // Expires at a specific time (timestamp or Date)\n storage.setItem('session', 'token-123', {\n expires: Date.now() + 60_000, // gone in 60 seconds\n })\n\n // Disposable: self-destructs after the first read\n storage.setItem('flash', 'You just signed in!', {\n disposable: true,\n })\n\n console.log(storage.flash) // 'You just signed in!'\n console.log(storage.flash) // undefined — consumed on first read\n\n\n`expires` accepts a timestamp, a date string, or a `Date` instance. Once the moment passes, the value is treated as gone — reading it returns `undefined` and the entry is cleaned up. `disposable` is a one-shot read: perfect for flash messages, single-use tokens, or any \"show this exactly once\" value.\n\nThe two can be combined, and there's a small family of helpers for managing lifetimes after the fact:\n\n\n\n // Set / inspect / clear an expiry on an existing key\n storage.setExpires('session', new Date('2030-01-01'))\n console.log(storage.getExpires('session')) // Date instance\n storage.removeExpires('session')\n\n // Mark an existing key as disposable\n storage.setDisposable('flash')\n\n // Inspect the full option set for a key\n console.log(storage.getOptions('session'))\n // e.g. { expires: <Date>, disposable: true } — or null if no options set\n\n\n`getOptions` returns whatever lifetime metadata is attached to a key (`expires`, `disposable`, or both), or `null` if the key is a plain value. It's a clean way to introspect state without guessing.\n\n## Cross-tab synchronization\n\nSame-tab reactivity is great, but sometimes you genuinely want every open tab to agree. Stokado can broadcast changes across tabs of the same origin using the `BroadcastChannel` API.\n\n\n\n import { createProxyStorage } from 'stokado'\n\n const storage = createProxyStorage(localStorage, {\n broadcast: true,\n channel: 'app-storage',\n })\n\n // In any tab:\n storage.on('theme', (next) => {\n document.documentElement.dataset.theme = next\n })\n\n // In another tab:\n storage.theme = 'dark' // every tab's listener fires\n\n\nWith `broadcast` enabled, a write in one tab propagates over the named channel and triggers your subscriptions in the others — so your same-tab `on`/`once` handlers and your cross-tab handlers share one unified API. The `channel` option lets you scope the broadcast so unrelated proxies don't talk over each other.\n\n## Quota alerts\n\nWeb Storage throws a `QuotaExceededError` when you exceed the (browser-dependent, typically ~5 MB) limit, and by then it's too late. Stokado lets you set a soft quota and get a callback _before_ a write lands.\n\n\n\n import { createProxyStorage, MB } from 'stokado'\n\n const storage = createProxyStorage(localStorage, {\n quota: 4 * MB,\n onQuotaExceeded(info) {\n console.warn(\n `Quota hit on \"${info.key}\": ${info.current}/${info.limit} bytes`,\n )\n // Return false to block this write entirely\n return false\n },\n })\n\n\nThe callback receives a `QuotaInfo` object with `{ current, limit, key, value }` — the projected total size, your configured limit, and the key/value being written. The return value controls the write:\n\n * Return `false` to **reject** the write (the value is not stored).\n * Return anything else (or nothing) to **allow** it.\n\n\n\nThe handler may also be `async`, which is useful when you want to evict old entries or ask the user before deciding:\n\n\n\n const storage = createProxyStorage(localStorage, {\n quota: 4 * MB,\n async onQuotaExceeded(info) {\n const proceed = await confirmWithUser(info)\n return proceed // false blocks, true allows\n },\n })\n\n\nFor proactive monitoring, `getUsage()` reports current consumption against the limit at any time:\n\n\n\n const { current, limit } = storage.getUsage()\n console.log(`Using ${current} of ${limit} bytes`)\n\n\nStokado measures byte size accurately (via `Blob`), so the numbers reflect real UTF-8 encoded sizes rather than naive string lengths. The exported `KB` and `MB` constants are just `1024` and `1024 * 1024` to keep your quota declarations readable.\n\n## Async backends\n\n`localStorage` caps out around 5 MB and is synchronous. For larger or non-blocking storage, localForage is the go-to — it transparently uses IndexedDB and exposes a Promise-based API. Stokado proxies it just as happily, returning an `AsyncProxyStorage` whose methods return Promises.\n\n\n\n import localforage from 'localforage'\n import { createProxyStorage } from 'stokado'\n\n const storage = createProxyStorage(localforage)\n\n // Wait for the backend to be ready before first use\n await storage.ready\n\n await storage.setItem('test', 'hello stokado')\n console.log(await storage.test) // 'hello stokado'\n\n await storage.removeItem('test')\n console.log(await storage.test) // undefined\n\n\nStokado auto-detects that the backend is asynchronous and adapts its surface accordingly. A few differences from the sync case to keep in mind:\n\n * Reads and writes resolve to Promises, so you `await` them (`await storage.test`, `await storage.setItem(...)`).\n * `ready` is a Promise you can await to ensure the backend has finished initializing.\n * `length()` is a method that returns a Promise, not a synchronous property.\n * `getUsage()` returns a Promise.\n\n\n\nEverything else — type preservation, subscriptions, expiration, disposable values — works identically. The same `setItem('key', value, { expires, disposable })`, the same `on`/`once`/`off`, just with `await` where the platform forces it.\n\n## Multi-platform presets\n\nBrowser `localStorage` is only one of many key-value stores in the JavaScript world. Stokado ships seven ready-made presets as sub-path imports, each adapting a foreign storage API to the shape `createProxyStorage` expects. Import only the one you need — they're tree-shakeable.\n\n**Cookies** — a `Storage`-like view over `document.cookie`:\n\n\n\n import { createProxyStorage } from 'stokado'\n import { cookieStorage } from 'stokado/presets/cookie'\n\n const cookies = createProxyStorage(cookieStorage)\n cookies.consent = true\n\n\n**WeChat / Douyin / Alipay mini-programs** — each exposes both a sync and an async variant mapping to that platform's storage SDK:\n\n\n\n import { createProxyStorage } from 'stokado'\n import { wechatStorage, wechatStorageAsync } from 'stokado/presets/wechat'\n import { douyinStorage, douyinStorageAsync } from 'stokado/presets/douyin'\n import { alipayStorage, alipayStorageAsync } from 'stokado/presets/alipay'\n\n const wx = createProxyStorage(wechatStorage)\n\n\n**uni-app** — the cross-platform mini-program framework, again sync and async:\n\n\n\n import { createProxyStorage } from 'stokado'\n import { uniStorage, uniStorageAsync } from 'stokado/presets/uni-app'\n\n const uni = createProxyStorage(uniStorage)\n\n\n**React Native** — wrap `@react-native-async-storage/async-storage` (or any compatible AsyncStorage) into an async storage-like object:\n\n\n\n import AsyncStorage from '@react-native-async-storage/async-storage'\n import { createProxyStorage } from 'stokado'\n import { createReactNativeStorage } from 'stokado/presets/react-native'\n\n const storage = createProxyStorage(createReactNativeStorage(AsyncStorage))\n await storage.ready\n await storage.setItem('token', 'abc')\n\n\n**Node** — an in-memory store, ideal for tests, SSR, and CLI tools where no browser storage exists:\n\n\n\n import { createProxyStorage } from 'stokado'\n import { memoryStorage } from 'stokado/presets/node'\n\n const storage = createProxyStorage(memoryStorage)\n storage.cached = { ok: true }\n\n\nBecause the presets normalize everything to the same storage-like interface, the entire stokado feature set — serialization, subscriptions, expiration — works the same across all of them. Write your data layer once, run it on the web, in a mini-program, in React Native, or on the server.\n\n## How it compares\n\nStokado is not trying to be the only storage tool you'll ever need, and it's worth being honest about where other libraries are the better pick.\n\n * **If your priority is squeezing data into a smaller footprint** , reach for lz-string. It's purpose-built for compressing strings before they hit storage. Stokado focuses on faithful type round-tripping, not compression.\n * **If you live entirely in Vue and want storage wired into the reactivity system** , useStorage from @vueuse/core gives you a `ref` that syncs to storage with deep Vue integration. Stokado is framework-agnostic by design and won't give you a Vue `ref` out of the box.\n * **If you need a large IndexedDB-backed key-value store and nothing else** , idb-keyval is a tiny, focused option. (And if you want stokado's features _on top_ of IndexedDB, recall that it proxies localForage, which sits on IndexedDB.)\n\n\n\nWhere stokado's edge shows is the _combination_ : faithful type preservation **and** same-tab reactivity **and** expiration/disposable values **and** quota awareness, all behind one plain-object interface, with zero dependencies and the same API across browsers, mini-programs, React Native, and Node. No single feature is unique; having all of them composed together, with nothing to install but the package itself, is.\n\n## Wrap-up\n\nThe Web Storage API gives you a synchronous string-to-string map and not much else. Stokado keeps that simplicity at the surface — `storage.token = 'abc'` — while quietly handling the parts every team reimplements: type-correct serialization, same-tab change subscriptions, TTLs and one-shot values, cross-tab sync, and quota guards. And it does it without dragging in a single dependency, across every JavaScript runtime that has a key-value store.\n\nIf you've been hand-rolling `JSON.parse` wrappers and `storage` event workarounds, give it a try:\n\n\n\n npm install stokado\n\n\n\n import { createProxyStorage } from 'stokado'\n\n const storage = createProxyStorage(localStorage)\n storage.user = { name: 'Ada', lastSeen: new Date() }\n\n\nThe full source, tests, and API reference live at github.com/KID-joker/stokado.",
"title": "Stokado: A Zero-Dependency Proxy Wrapper That Makes Browser Storage Feel Like a Plain Object"
}