Type-safe, synchronous state management for the browser with a unified API over localStorage, sessionStorage, cookies, and in-memory storage. Each backend exposes the same interface and an isolated observer for change notifications.
- Installation
- Overview
- Storage backends
- Reading and writing values
- Type casting
- Observing changes
- React integration
- API reference
- Error handling
- License
npm install @sovgut/stateyarn add @sovgut/statepnpm add @sovgut/stateThe package ships as ES modules with bundled type declarations and has no runtime dependencies.
import { LocalState } from "@sovgut/state";
LocalState.set("user", { id: 1, name: "Alice" });
const user = LocalState.get("user");
LocalState.on("user", (value) => {
console.log("user changed:", value);
});LocalState, SessionState, MemoryState, and CookieState are static classes. There is nothing to instantiate; import a backend and call its methods directly.
All backends share the same core interface. They differ in where data is stored and how long it survives.
| Backend | Storage | Lifetime |
|---|---|---|
LocalState |
localStorage |
Persists across browser sessions |
SessionState |
sessionStorage |
Cleared when the tab is closed |
MemoryState |
In-memory Map |
Cleared on page reload |
CookieState |
document.cookie |
Configurable via cookie attributes |
import { LocalState } from "@sovgut/state";
LocalState.set("theme", "dark");
const theme = LocalState.get("theme"); // "dark"import { SessionState } from "@sovgut/state";
SessionState.set("draft", { subject: "", body: "" });
const draft = SessionState.get("draft");Values are stored by reference without a JSON roundtrip, so Date, Map, Set, and class instances survive a get/set cycle unchanged. Stored objects are not defensively copied.
import { MemoryState } from "@sovgut/state";
MemoryState.set("cache", new Map([["key", "value"]]));
const cache = MemoryState.get<Map<string, string>>("cache");set accepts standard cookie attributes. path defaults to / and sameSite defaults to lax. When sameSite is none, secure is enabled automatically.
import { CookieState } from "@sovgut/state";
CookieState.set("sessionId", "abc123", {
expires: 7, // days from now, or a Date
secure: true,
sameSite: "strict",
});Cookies set with a custom path or domain must be removed with the same values, because browsers require an exact match to delete a cookie:
CookieState.set("token", value, { path: "/admin" });
CookieState.remove("token", { path: "/admin" });Values written to LocalState, SessionState, and CookieState are JSON-serialized. A value that cannot be serialized — undefined, a function, a symbol, or a circular reference — causes set to throw.
LocalState.set("count", 42);
LocalState.set("name", "Alice");
LocalState.set("isActive", true);
LocalState.set("tags", ["typescript", "state"]);
LocalState.set("settings", {
theme: "dark",
notifications: { email: true, push: false },
});get returns the stored value, or undefined when the key is absent.
const count = LocalState.get("count"); // 42
const missing = LocalState.get("nonexistent"); // undefinedProvide a fallback to return a default when the key is absent or holds an empty value. The fallback also drives the inferred return type.
const theme = LocalState.get("theme", { fallback: "light" }); // string
const score = LocalState.get("score", { fallback: 0 }); // number
const tags = LocalState.get("tags", { fallback: [] as string[] }); // string[]Use strict to throw StateDoesNotExist when the key is absent.
try {
const token = LocalState.get("token", { strict: true });
} catch (error) {
// key was not present
}LocalState.remove("user"); // remove one key
LocalState.clear(); // remove all keys for this backend
LocalState.has("user"); // booleanProvide a cast option to coerce a stored value to a primitive type. Supported targets are "string", "number", "boolean", and "bigint".
LocalState.set("value", "42");
LocalState.get("value", { cast: "string" }); // "42"
LocalState.get("value", { cast: "number" }); // 42
LocalState.get("value", { cast: "boolean" }); // true
LocalState.get("value", { cast: "bigint" }); // 42nAn impossible cast — for example "abc" to number — returns the fallback when one is provided, or throws StateInvalidCast when strict is set. Boolean casting treats "false", "0", and "" as false.
A generic parameter can be supplied to type the return value directly:
interface User {
id: number;
name: string;
}
const user = LocalState.get<User>("currentUser");
const required = LocalState.get<User>("currentUser", { strict: true });Each backend maintains its own listener registry, so LocalState listeners do not fire on SessionState events. Listeners receive the new value, or null when a key is removed or cleared.
const handler = (value) => console.log("changed:", value);
LocalState.on("user", handler); // subscribe
LocalState.once("user", handler); // fire at most once
LocalState.off("user", handler); // unsubscribeNote: storing an explicit null via set emits null as well, so a listener cannot distinguish "set to null" from "removed" by the payload alone.
Additional inspection and cleanup methods are available:
LocalState.listenerCount("user"); // number of listeners for a key
LocalState.eventNames(); // keys with at least one listener
LocalState.removeListener("user"); // remove all listeners for one key
LocalState.removeAllListeners(); // remove every listener on the backendA listener that throws is isolated: the error is logged and other listeners still run.
The library is framework-agnostic. A minimal hook that keeps component state synchronized with a backend:
import { useCallback, useEffect, useState } from "react";
import { LocalState, type IStorageEventData } from "@sovgut/state";
function useLocalState<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() =>
LocalState.get(key, { fallback: initialValue }),
);
useEffect(() => {
const handler = (event: IStorageEventData<T>) => {
setValue(event ?? initialValue);
};
LocalState.on(key, handler);
return () => LocalState.off(key, handler);
}, [key, initialValue]);
const update = useCallback(
(next: T) => LocalState.set(key, next),
[key],
);
return [value, update] as const;
}
function ThemeToggle() {
const [theme, setTheme] = useLocalState("theme", "light");
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
{theme}
</button>
);
}The methods below are shared by all four backends unless noted otherwise.
Retrieves a value from storage.
| Option | Type | Description |
|---|---|---|
fallback |
T |
Returned when the key is absent or holds an empty value. |
strict |
boolean |
Throw StateDoesNotExist when the key is absent. |
cast |
"string" | "number" | "boolean" | "bigint" |
Coerce the stored value to a primitive type. |
Stores a value. Throws when the value cannot be serialized or the backend rejects the write. CookieState.set(key, value, options?) additionally accepts cookie attributes:
| Option | Type | Description |
|---|---|---|
expires |
Date | number |
Expiration date, or number of days from now. |
maxAge |
number |
Maximum age in seconds. Takes precedence over expires. |
domain |
string |
Domain the cookie is scoped to. |
path |
string |
Path the cookie is scoped to. Defaults to /. |
secure |
boolean |
Send only over HTTPS. Required when sameSite is none. |
sameSite |
"strict" | "lax" | "none" |
SameSite policy. Defaults to lax. |
Removes a value. CookieState.remove(key, options?) accepts path and domain, which must match the values used when the cookie was set.
Removes every value for the backend and emits null for each removed key.
Returns whether a key exists.
| Method | Description |
|---|---|
on(event, callback) |
Add a listener. |
once(event, callback) |
Add a listener that fires at most once. |
off(event, callback) |
Remove a specific listener. |
removeListener(event) |
Remove all listeners for one key. |
removeAllListeners() |
Remove every listener on the backend. |
listenerCount(event) |
Number of listeners registered for a key. |
eventNames() |
Keys that have at least one listener. |
Both error types carry structured fields for programmatic handling.
import { StateDoesNotExist, StateInvalidCast } from "@sovgut/state";
try {
LocalState.get("token", { strict: true });
} catch (error) {
if (error instanceof StateDoesNotExist) {
console.error(`"${error.key}" not found in ${error.storage}`);
}
}
try {
LocalState.set("value", "not-a-number");
LocalState.get("value", { cast: "number", strict: true });
} catch (error) {
if (error instanceof StateInvalidCast) {
console.error(
`cannot cast "${error.value}" to ${error.type} for "${error.key}"`,
);
}
}Released under the MIT License.