Local-first state for modern web apps.
PocketState is a small framework-agnostic TypeScript library for browser state that should feel durable by default: it can survive refreshes, persist locally, sync across tabs, restore itself on startup, and keep undo/redo history without adding a backend.
Most state libraries focus on in-memory updates inside a single runtime. That is useful, but it leaves a gap for browser apps that should feel continuous. PocketState targets that gap: local-first continuity in the browser.
It gives you one store API for:
- reactive in-memory state
- IndexedDB persistence
- automatic rehydration
- cross-tab sync with BroadcastChannel
- undo and redo history
- graceful browser API fallbacks
- Framework-agnostic core library in
packages/pocketstate - Durable browser state with
ready()-based hydration - Named actions with mutable draft-style handlers
- Cross-tab syncing with event deduplication
- Configurable undo/redo history limits
- Optional storage, sync, and history configuration
- Vite demo app in
apps/demo - Offline demo support with a small service worker
- Vitest coverage for core behavior, persistence, history, and sync logic
Published package usage:
npm install pocketstate-workspaceRepository usage:
npm installimport { createStore } from "pocketstate";
type Task = {
id: string;
text: string;
done: boolean;
};
const tasks = createStore("tasks", {
initial: {
items: [] as Task[],
filter: "all" as "all" | "open" | "done",
},
persist: true,
syncTabs: true,
history: {
enabled: true,
limit: 50,
},
});
tasks.action("addTask", (state, text: string) => {
state.items.push({
id: crypto.randomUUID(),
text,
done: false,
});
});
tasks.action("toggleTask", (state, id: string) => {
const task = state.items.find((item) => item.id === id);
if (task) {
task.done = !task.done;
}
});
await tasks.ready();
tasks.run("addTask", "Buy coffee");
tasks.undo();
tasks.redo();const preferences = createStore("preferences", {
initial: {
theme: "light" as "light" | "dark",
density: "comfortable" as "comfortable" | "compact",
},
persist: {
enabled: true,
version: 2,
migrate: (stored, fromVersion) => {
if (fromVersion === 1) {
const legacy = stored as { theme: "light" | "dark" };
return {
theme: legacy.theme,
density: "comfortable",
};
}
return stored as {
theme: "light" | "dark";
density: "comfortable" | "compact";
};
},
},
});
await preferences.ready();const board = createStore("board", {
initial: { cards: [] as string[] },
syncTabs: true,
});
board.subscribe((state, meta) => {
console.log("source:", meta.lastSource, "cards:", state.cards.length);
});const editor = createStore("editor", {
initial: { value: "" },
history: {
enabled: true,
limit: 100,
},
});
editor.setState((draft) => {
draft.value = "Hello";
});
editor.undo();
editor.redo();The demo is a small vanilla TypeScript app called PocketState Tasks.
Run it locally:
npm run dev:demoBuild everything:
npm run buildRun tests:
npm testWhat to try in the demo:
- Create a few tasks and refresh the page.
- Open a second tab and watch changes sync.
- Use undo and redo after a few edits.
- After the app has loaded once, switch the browser offline and reload.
PocketState is split into a few small parts:
core: store lifecycle, subscriptions, action execution, public APIpersistence: IndexedDB adapter, memory fallback, hydration and migration logicsync: BroadcastChannel transport and event deduplicationhistory: bounded undo/redo snapshot managerdemo: vanilla Vite app with service worker caching
The core uses deep-cloned draft updates instead of bringing in a heavy immutable helper. That keeps the API ergonomic while keeping the implementation easy to read.
Those tools solve a different primary problem. They are good at app state modeling, updates, and integrations. PocketState is aimed at durable browser continuity: local persistence, hydration, cross-tab awareness, and recovery as first-class concerns.
You can think of it as a state engine for apps that should keep their place, not as a replacement for every state library.
- State should stay serializable if you want persistence and sync to behave predictably.
- Cross-tab sync depends on
BroadcastChannel; unsupported browsers simply skip it. - If
IndexedDBis unavailable, PocketState falls back to memory and reports that mode in store metadata. - History currently stores full snapshots instead of diffs.
- selective persistence and partial hydration
- pluggable storage adapters beyond IndexedDB
- framework bindings for React, Vue, and Svelte
- devtools-friendly inspection hooks
- optional conflict handling strategies beyond last-write-wins