Skip to content

needlag/pocketstate

Repository files navigation

PocketState

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.

Why PocketState exists

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

Features

  • 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

Install

Published package usage:

npm install pocketstate-workspace

Repository usage:

npm install

Quick start

import { 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();

Persistence example

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();

Cross-tab sync example

const board = createStore("board", {
  initial: { cards: [] as string[] },
  syncTabs: true,
});

board.subscribe((state, meta) => {
  console.log("source:", meta.lastSource, "cards:", state.cards.length);
});

History example

const editor = createStore("editor", {
  initial: { value: "" },
  history: {
    enabled: true,
    limit: 100,
  },
});

editor.setState((draft) => {
  draft.value = "Hello";
});

editor.undo();
editor.redo();

Demo

The demo is a small vanilla TypeScript app called PocketState Tasks.

Run it locally:

npm run dev:demo

Build everything:

npm run build

Run tests:

npm test

What 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.

Architecture summary

PocketState is split into a few small parts:

  • core: store lifecycle, subscriptions, action execution, public API
  • persistence: IndexedDB adapter, memory fallback, hydration and migration logic
  • sync: BroadcastChannel transport and event deduplication
  • history: bounded undo/redo snapshot manager
  • demo: 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.

Why not just use Redux, Zustand, or another state manager?

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.

Limitations

  • 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 IndexedDB is unavailable, PocketState falls back to memory and reports that mode in store metadata.
  • History currently stores full snapshots instead of diffs.

Roadmap

  • 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

About

Local-first state for modern web apps.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors