@da-core/browser-storage-manager
v0.2.0
Published
Typed browser storage manager with pluggable drivers and codecs.
Downloads
536
Readme
@da-core/browser-storage-manager
Typed browser storage manager with pluggable drivers and codecs.
The goal of this package is to give your application one explicit place for defining persistent browser-side values:
- what can be stored,
- under which key,
- in which storage backend,
- with which runtime encoding/decoding strategy,
- and with fully inferred TypeScript types.
Instead of using localStorage, cookies, sessionStorage, or IndexedDB directly across the application, you define a typed StorageManager once and use it everywhere.
Installation
npm install @da-core/browser-storage-managerOptional integrations:
npm install react
npm install zodreact and zod are optional peer dependencies. You only need them when importing from:
import { useStorageValue } from "@da-core/browser-storage-manager/react";
import { zodJsonCodec } from "@da-core/browser-storage-manager/zod";Basic idea
Create a project-local StorageManager.ts file:
import {
createStorageManager,
localStorageDriver,
cookieStorageDriver,
jsonCodec,
stringCodec,
} from "@da-core/browser-storage-manager";
type Theme = "light" | "dark" | "system";
export const StorageManager = createStorageManager({
prefix: "my-app",
entries: {
language: {
key: "language",
driver: cookieStorageDriver(),
codec: stringCodec(),
defaultValue: "en",
},
theme: {
key: "theme",
driver: localStorageDriver(),
codec: jsonCodec<Theme>(),
defaultValue: "system",
},
},
});Use it anywhere:
const theme = await StorageManager.theme.getOrDefault();
await StorageManager.theme.set("dark");
await StorageManager.theme.remove();The type of theme is inferred as:
Theme;The type of await StorageManager.theme.get() is:
Theme | null;Why use this package?
Browser storage usually becomes scattered over time:
localStorage.getItem("theme");
localStorage.setItem("theme", JSON.stringify(value));
document.cookie = "...";That makes it hard to answer simple questions:
- Which values are persisted?
- Which keys exist?
- Which values are cookies and which are local storage?
- What is the expected type of a stored value?
- What happens when stored data is malformed?
- Can this value be moved from
localStorageto IndexedDB later?
This package centralizes those decisions.
Your application code should depend on this:
await StorageManager.theme.get();not on this:
localStorage.getItem("my-app:theme");Core concepts
The package is intentionally built around four small concepts.
Storage manager
A typed registry of persistent values.
const StorageManager = createStorageManager({
prefix: "my-app",
entries: {
theme: {
key: "theme",
driver: localStorageDriver(),
codec: jsonCodec<Theme>(),
},
},
});Storage entry
A single typed value.
await StorageManager.theme.get();
await StorageManager.theme.set("dark");
await StorageManager.theme.remove();Storage driver
A backend that stores raw strings.
Built-in drivers:
localStorageDriver()
sessionStorageDriver()
cookieStorageDriver()
indexedDbStorageDriver(...)
memoryStorageDriver()Storage codec
A serializer/deserializer for a specific value type.
Built-in codecs:
stringCodec();
booleanCodec();
numberCodec();
dateCodec();
jsonCodec<T>();Creating a StorageManager
import {
createStorageManager,
localStorageDriver,
sessionStorageDriver,
cookieStorageDriver,
indexedDbStorageDriver,
stringCodec,
booleanCodec,
jsonCodec,
} from "@da-core/browser-storage-manager";
type Theme = "light" | "dark" | "system";
type CookieConsent = {
necessary: true;
analytics: boolean;
marketing: boolean;
};
type EditorDraft = {
title: string;
content: string;
updatedAt: string;
};
export const StorageManager = createStorageManager({
prefix: "dashboard",
entries: {
theme: {
key: "theme",
driver: localStorageDriver(),
codec: jsonCodec<Theme>(),
defaultValue: "system",
},
language: {
key: "language",
driver: cookieStorageDriver(),
codec: stringCodec(),
defaultValue: "en",
},
sidebarCollapsed: {
key: "sidebar-collapsed",
driver: localStorageDriver(),
codec: booleanCodec(),
defaultValue: false,
},
sessionNoticeDismissed: {
key: "session-notice-dismissed",
driver: sessionStorageDriver(),
codec: booleanCodec(),
defaultValue: false,
},
cookieConsent: {
key: "cookie-consent",
driver: localStorageDriver(),
codec: jsonCodec<CookieConsent>(),
defaultValue: {
necessary: true,
analytics: false,
marketing: false,
},
},
editorDraft: {
key: "editor-draft",
driver: indexedDbStorageDriver({
databaseName: "dashboard",
storeName: "drafts",
version: 1,
}),
codec: jsonCodec<EditorDraft>(),
},
},
});Storage entry API
Every storage entry exposes the same API.
type StorageEntry<T> = {
readonly key: string;
readonly fullKey: string;
get(): Promise<T | null>;
getOrDefault(): Promise<T>;
set(value: T): Promise<void>;
remove(): Promise<void>;
exists(): Promise<boolean>;
update(updater: (current: T | null) => T): Promise<void>;
resetToDefault(): Promise<void>;
};get()
Reads the value from storage.
const value = await StorageManager.theme.get();Return type:
Theme | null;Returns null when the value does not exist.
getOrDefault()
Reads the value from storage. If it does not exist, returns the configured defaultValue.
const theme = await StorageManager.theme.getOrDefault();Return type:
Theme;If no defaultValue was configured and the value does not exist, this method may throw.
set(value)
Stores a value.
await StorageManager.theme.set("dark");The accepted value is fully typed.
await StorageManager.theme.set("invalid");
// TypeScript error when Theme does not include "invalid"remove()
Removes the value from storage.
await StorageManager.theme.remove();exists()
Checks whether a value exists.
const hasTheme = await StorageManager.theme.exists();update(updater)
Reads the current value, computes the next value, and stores it.
await StorageManager.theme.update((current) => {
if (current === "dark") {
return "light";
}
return "dark";
});For object values:
await StorageManager.cookieConsent.update((current) => ({
necessary: true,
analytics: current?.analytics ?? false,
marketing: true,
}));The package does not deep-merge objects automatically. This is intentional.
Explicit updates are safer than implicit deep merging because browser storage often contains old or malformed data.
resetToDefault()
Stores the configured defaultValue.
await StorageManager.theme.resetToDefault();This is different from remove():
await StorageManager.theme.remove();
// value no longer exists
await StorageManager.theme.resetToDefault();
// value exists and equals defaultValueKey generation
Given this manager:
const StorageManager = createStorageManager({
prefix: "my-app",
entries: {
theme: {
key: "theme",
driver: localStorageDriver(),
codec: jsonCodec<Theme>(),
},
},
});The entry has:
StorageManager.theme.key;
// "theme"
StorageManager.theme.fullKey;
// "my-app:theme"You can customize the separator:
const StorageManager = createStorageManager({
prefix: "my-app",
separator: ".",
entries: {
theme: {
key: "theme",
driver: localStorageDriver(),
codec: jsonCodec<Theme>(),
},
},
});Result:
StorageManager.theme.fullKey;
// "my-app.theme"Drivers
Drivers store raw strings. They do not know anything about your application-specific types.
localStorageDriver
import { localStorageDriver } from "@da-core/browser-storage-manager";
const driver = localStorageDriver();Usage:
theme: {
key: "theme",
driver: localStorageDriver(),
codec: jsonCodec<Theme>(),
}The driver is browser-safe. When window is unavailable, it behaves defensively instead of crashing during server-side evaluation.
sessionStorageDriver
import { sessionStorageDriver } from "@da-core/browser-storage-manager";
const driver = sessionStorageDriver();Usage:
temporaryBannerDismissed: {
key: "temporary-banner-dismissed",
driver: sessionStorageDriver(),
codec: booleanCodec(),
defaultValue: false,
}cookieStorageDriver
import { cookieStorageDriver } from "@da-core/browser-storage-manager";
const driver = cookieStorageDriver();Usage:
language: {
key: "language",
driver: cookieStorageDriver(),
codec: stringCodec(),
defaultValue: "en",
}Useful for small values that should be stored in cookies, such as language or consent state.
indexedDbStorageDriver
import { indexedDbStorageDriver } from "@da-core/browser-storage-manager";
const driver = indexedDbStorageDriver({
databaseName: "my-app",
storeName: "storage",
version: 1,
});Usage:
editorDraft: {
key: "editor-draft",
driver: indexedDbStorageDriver({
databaseName: "my-app",
storeName: "drafts",
version: 1,
}),
codec: jsonCodec<EditorDraft>(),
}IndexedDB is useful for larger values or values that you do not want to keep in localStorage.
memoryStorageDriver
import { memoryStorageDriver } from "@da-core/browser-storage-manager";
const driver = memoryStorageDriver();Useful for tests, storybooks, temporary environments, or non-browser execution.
const StorageManager = createStorageManager({
prefix: "test",
entries: {
theme: {
key: "theme",
driver: memoryStorageDriver(),
codec: jsonCodec<Theme>(),
defaultValue: "system",
},
},
});Codecs
Codecs convert typed values to raw strings and raw strings back to typed values.
stringCodec
import { stringCodec } from "@da-core/browser-storage-manager";
const codec = stringCodec();Stores strings as-is.
language: {
key: "language",
driver: cookieStorageDriver(),
codec: stringCodec(),
}booleanCodec
import { booleanCodec } from "@da-core/browser-storage-manager";
const codec = booleanCodec();Stores booleans as "true" or "false".
sidebarCollapsed: {
key: "sidebar-collapsed",
driver: localStorageDriver(),
codec: booleanCodec(),
defaultValue: false,
}numberCodec
import { numberCodec } from "@da-core/browser-storage-manager";
const codec = numberCodec();Stores finite numbers.
fontScale: {
key: "font-scale",
driver: localStorageDriver(),
codec: numberCodec(),
defaultValue: 1,
}dateCodec
import { dateCodec } from "@da-core/browser-storage-manager";
const codec = dateCodec();Stores dates as ISO strings.
lastSeenAt: {
key: "last-seen-at",
driver: localStorageDriver(),
codec: dateCodec(),
}jsonCodec<T>
import { jsonCodec } from "@da-core/browser-storage-manager";
const codec = jsonCodec<UserPreferences>();Stores any JSON-serializable value.
preferences: {
key: "preferences",
driver: localStorageDriver(),
codec: jsonCodec<UserPreferences>(),
}jsonCodec<T>() trusts the stored value after JSON.parse.
For runtime validation, use zodJsonCodec.
Runtime validation with Zod
Import from the optional Zod subpath:
import { zodJsonCodec } from "@da-core/browser-storage-manager/zod";Example:
import { z } from "zod";
import { createStorageManager, localStorageDriver } from "@da-core/browser-storage-manager";
import { zodJsonCodec } from "@da-core/browser-storage-manager/zod";
const ThemeSchema = z.enum(["light", "dark", "system"]);
export const StorageManager = createStorageManager({
prefix: "my-app",
entries: {
theme: {
key: "theme",
driver: localStorageDriver(),
codec: zodJsonCodec(ThemeSchema),
defaultValue: "system",
},
},
});Now invalid stored data fails during decoding instead of being trusted as Theme.
React integration
React support is available through a separate subpath:
import { useStorageValue } from "@da-core/browser-storage-manager/react";This keeps React out of the core package unless you explicitly use it.
useStorageValue
useStorageValue binds a StorageEntry<T> to React state.
const storageState = useStorageValue(StorageManager.theme);Return shape
The hook returns an object:
type UseStorageValueResult<T> = {
value: T | null;
isLoading: boolean;
error: unknown;
reload(): Promise<void>;
setValue(value: T): Promise<void>;
remove(): Promise<void>;
};Example
import { StorageManager } from "./StorageManager";
import { useStorageValue } from "@da-core/browser-storage-manager/react";
export function ThemeSwitcher() {
const { value: theme, isLoading, error, setValue, remove, reload } = useStorageValue(StorageManager.theme);
if (isLoading) {
return <p>Loading theme...</p>;
}
if (error) {
return (
<div>
<p>Could not load theme.</p>
<button type="button" onClick={() => void reload()}>
Try again
</button>
</div>
);
}
return (
<section>
<p>Current theme: {theme ?? "not selected"}</p>
<button type="button" onClick={() => void setValue("light")}>
Light
</button>
<button type="button" onClick={() => void setValue("dark")}>
Dark
</button>
<button type="button" onClick={() => void setValue("system")}>
System
</button>
<button type="button" onClick={() => void remove()}>
Remove
</button>
</section>
);
}Notes
value can be null because get() returns T | null.
If you always want a fallback value, configure defaultValue on the entry and call getOrDefault() manually where needed.
The hook intentionally exposes the nullable state directly. This keeps the distinction clear between:
value does not existand:
value exists and equals a defaultFull example
// StorageManager.ts
import {
createStorageManager,
localStorageDriver,
cookieStorageDriver,
indexedDbStorageDriver,
jsonCodec,
stringCodec,
booleanCodec,
} from "@da-core/browser-storage-manager";
type Theme = "light" | "dark" | "system";
type CookieConsent = {
necessary: true;
analytics: boolean;
};
type Draft = {
title: string;
body: string;
updatedAt: string;
};
export const StorageManager = createStorageManager({
prefix: "my-product",
entries: {
theme: {
key: "theme",
driver: localStorageDriver(),
codec: jsonCodec<Theme>(),
defaultValue: "system",
},
language: {
key: "language",
driver: cookieStorageDriver(),
codec: stringCodec(),
defaultValue: "en",
},
sidebarCollapsed: {
key: "sidebar-collapsed",
driver: localStorageDriver(),
codec: booleanCodec(),
defaultValue: false,
},
cookieConsent: {
key: "cookie-consent",
driver: localStorageDriver(),
codec: jsonCodec<CookieConsent>(),
defaultValue: {
necessary: true,
analytics: false,
},
},
draft: {
key: "draft",
driver: indexedDbStorageDriver({
databaseName: "my-product",
storeName: "drafts",
version: 1,
}),
codec: jsonCodec<Draft>(),
},
},
});Usage:
import { StorageManager } from "./StorageManager";
const theme = await StorageManager.theme.getOrDefault();
await StorageManager.theme.set("dark");
await StorageManager.cookieConsent.update((current) => ({
necessary: true,
analytics: current?.analytics ?? false,
}));
await StorageManager.draft.set({
title: "Hello",
body: "Draft content",
updatedAt: new Date().toISOString(),
});React usage:
import { StorageManager } from "./StorageManager";
import { useStorageValue } from "@da-core/browser-storage-manager/react";
export function SidebarToggle() {
const { value, isLoading, setValue } = useStorageValue(StorageManager.sidebarCollapsed);
if (isLoading) {
return null;
}
const collapsed = value ?? false;
return (
<button type="button" onClick={() => void setValue(!collapsed)}>
{collapsed ? "Expand sidebar" : "Collapse sidebar"}
</button>
);
}Error handling
Storage access may fail.
Examples:
- unavailable browser APIs,
- denied storage access,
- malformed JSON,
- invalid codec data,
- IndexedDB errors,
- quota exceeded errors.
Use try/catch for direct entry access:
try {
const preferences = await StorageManager.preferences.get();
} catch (error) {
console.error("Could not read preferences", error);
}In React, use the error returned by useStorageValue:
const { value, error, reload } = useStorageValue(StorageManager.theme);
if (error) {
return (
<button type="button" onClick={() => void reload()}>
Retry
</button>
);
}SSR notes
The browser drivers are designed to avoid crashing when evaluated outside the browser.
That said, actual browser storage values are only available in the browser.
For SSR frameworks, prefer reading storage in client-side code, effects, event handlers, or React components that run on the client.
Example with Next.js App Router:
"use client";
import { useStorageValue } from "@da-core/browser-storage-manager/react";
import { StorageManager } from "./StorageManager";
export function ThemeSwitcher() {
const { value, setValue, isLoading } = useStorageValue(StorageManager.theme);
if (isLoading) {
return null;
}
return (
<button type="button" onClick={() => void setValue("dark")}>
Current theme: {value ?? "none"}
</button>
);
}Package exports
Main API:
import { createStorageManager, localStorageDriver, jsonCodec } from "@da-core/browser-storage-manager";React integration:
import { useStorageValue } from "@da-core/browser-storage-manager/react";Zod integration:
import { zodJsonCodec } from "@da-core/browser-storage-manager/zod";Publishing checklist
Before publishing a new version:
npm run clean
npm run build
npm test
npm pack --dry-run
npm publishFor a scoped public package, the first publish may require:
npm publish --access publicDesign decisions
Why is the API async?
Even though localStorage and cookies are synchronous, IndexedDB is asynchronous.
The package exposes a single async API so that entries can move between drivers without changing application code.
This means this code remains stable:
await StorageManager.theme.get();even if theme later moves from localStorage to IndexedDB or another async driver.
Why codecs instead of a raw flag?
A boolean flag like this:
raw: true;does not scale well.
A codec is explicit:
codec: stringCodec();
codec: jsonCodec<Theme>();
codec: booleanCodec();
codec: zodJsonCodec(schema);It makes serialization a first-class part of the storage definition.
Why no automatic deep merge?
Automatic deep merge looks convenient but can be dangerous for persisted data.
It is unclear how to merge:
- arrays,
null,- dates,
- old object shapes,
- removed fields,
- nested settings.
This package uses explicit replacement/update semantics instead:
await StorageManager.preferences.update((current) => ({
...defaultPreferences,
...current,
compactMode: true,
}));Explicit updates are more predictable for long-lived applications.
License
MIT
