Skip to content

777genius/state-sync

Repository files navigation

state-sync

Scalable, transport-agnostic state synchronization for multi-window and multi-process apps -- fully typed TypeScript with framework adapters for Redux, Zustand, Jotai, MobX, Pinia, Valtio, Svelte, and Vue.

Documentation | Comparison | GitHub


You provide:

  • a subscriber (an invalidation signal: "something changed, refresh!")
  • a provider (fetches a snapshot: { revision, data })
  • an applier (applies the snapshot to your local state)

state-sync provides:

  • a small engine that handles common hard parts: coalescing, retry hooks, lifecycle, and avoiding many race conditions around refresh/apply.

Table of contents

What is this?

state-sync is a framework-agnostic, transport-agnostic way to keep one logical piece of state consistent across multiple runtimes.

Typical use cases:

  • Tauri: sync settings/auth/cache between windows
  • Web: sync state between tabs/iframes (planned via BroadcastChannel/adapters)
  • Any IPC where you can emit an invalidation + fetch a snapshot

Non-goals:

  • Realtime CRDT merging or fine-grained patches. The model here is "invalidate -> fetch canonical snapshot -> apply".

Why state-sync?

  • Tiny footprint. The core engine is 3.1 KB gzipped; each framework adapter adds roughly 0.8 KB.
  • No vendor lock-in. Works with any transport (Tauri events, BroadcastChannel, WebSocket, custom IPC) and any state library.
  • One API for every framework. The same createRevisionSync call works with Redux, Zustand, Jotai, MobX, Pinia, Valtio, Svelte, and Vue -- swap one line to switch adapters.

See the full comparison with alternatives for details.

How it works (concepts)

  • Topic: string identifier for a resource ('settings', 'profile', 'cache:user:123')
  • Revision: monotonic-ish version identifier for ordering (string)
  • Invalidation event: "your snapshot may be stale" (subscriber emits these)
  • Snapshot provider: returns an envelope { revision, data }
  • Applier: takes the snapshot and mutates your local state

If invalidations come in fast (or the transport drops/duplicates), the engine aims to behave well:

  • it coalesces refresh requests
  • it keeps a consistent lifecycle (start(), stop(), refresh())
  • it exposes structured error hooks to help observe retry/refresh failures

Packages

Package What it is
@statesync/core Engine + revision protocol + types
@statesync/persistence Persistence layer (localStorage, IndexedDB, cross-tab sync)
@statesync/redux Redux snapshot applier adapter (HOF reducer wrapping)
@statesync/pinia Pinia snapshot applier adapter
@statesync/zustand Zustand snapshot applier adapter
@statesync/jotai Jotai snapshot applier adapter (atom-based)
@statesync/mobx MobX snapshot applier adapter (in-place mutation)
@statesync/valtio Valtio snapshot applier adapter
@statesync/svelte Svelte snapshot applier adapter
@statesync/vue Vue (reactive/ref) snapshot applier adapter
@statesync/tauri Tauri transport adapters (subscriber + provider)
state-sync (Rust) Shared protocol types for Rust backends (Revision, SnapshotEnvelope<T>, InvalidationEvent)

Install

npm install @statesync/core

# Persistence (optional):
npm install @statesync/persistence  # localStorage, IndexedDB, cross-tab sync

# Framework adapters (pick one):
npm install @statesync/redux    # Redux / RTK
npm install @statesync/pinia    # Pinia
npm install @statesync/zustand  # Zustand
npm install @statesync/jotai    # Jotai
npm install @statesync/mobx     # MobX
npm install @statesync/valtio   # Valtio
npm install @statesync/svelte   # Svelte
npm install @statesync/vue      # Vue (reactive / ref)

# Transport adapter:
npm install @statesync/tauri    # Tauri v2

npm @statesync/core npm @statesync/persistence npm @statesync/pinia npm @statesync/zustand npm @statesync/valtio npm @statesync/svelte npm @statesync/vue npm @statesync/redux npm @statesync/jotai npm @statesync/mobx npm @statesync/tauri CI bundle size

Quickstart (core)

import { createConsoleLogger, createRevisionSync } from '@statesync/core';

const handle = createRevisionSync({
  topic: 'settings',
  subscriber: myInvalidationSubscriber, // emits "changed" events
  provider: mySnapshotProvider, // returns { revision, data }
  applier: {
    apply(snapshot) {
      // snapshot.data is your payload
      console.log('Apply:', snapshot.revision, snapshot.data);
    },
  },
  logger: createConsoleLogger({ debug: true }),
  onError(ctx) {
    // ctx.phase: 'subscribe' | 'getSnapshot' | 'apply' | ...
    console.error(`[${ctx.phase}]`, ctx.error);
  },
});

await handle.start();

Quickstart (Tauri)

Use Tauri events for invalidation and invoke for snapshots.

import { createRevisionSync } from '@statesync/core';
import {
  createTauriInvalidationSubscriber,
  createTauriSnapshotProvider,
} from '@statesync/tauri';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';

const handle = createRevisionSync({
  topic: 'settings',
  subscriber: createTauriInvalidationSubscriber({
    listen,
    eventName: 'state-sync:invalidation',
  }),
  provider: createTauriSnapshotProvider({
    invoke,
    commandName: 'get_snapshot',
    args: { topic: 'settings' },
  }),
  applier: myApplier, // use any framework adapter below
});

await handle.start();

Framework adapters

Each adapter creates a SnapshotApplier for a specific state management library. All adapters share the same options pattern: mode ('patch' | 'replace'), pickKeys/omitKeys, toState mapping, and strict mode.

import { createPiniaSnapshotApplier } from '@statesync/pinia';

const applier = createPiniaSnapshotApplier(myPiniaStore, {
  mode: 'patch',
  omitKeys: ['localUiFlag'],
});

Every framework adapter follows the same pattern -- swap createPiniaSnapshotApplier for createReduxSnapshotApplier, createZustandSnapshotApplier, createJotaiSnapshotApplier, createMobXSnapshotApplier, createValtioSnapshotApplier, createSvelteSnapshotApplier, or createVueSnapshotApplier. See the adapter documentation for full examples of each.

Adapter options (shared across all adapters)

Option Type Default Description
mode 'patch' | 'replace' 'patch' Merge vs full replacement
pickKeys string[] -- Only sync these keys
omitKeys string[] -- Never sync these keys
toState (data, ctx) => State identity Map snapshot data to state shape
strict boolean true Throw if toState returns non-object

Docs & examples

Full documentation is hosted at 777genius.github.io/state-sync.

Development

pnpm install
pnpm lint
pnpm typecheck
pnpm test
pnpm build

Rust crate

The state-sync Rust crate provides shared protocol types for Tauri (or any Rust) backends:

  • Revision -- monotonic u64 counter with saturating arithmetic
  • SnapshotEnvelope<T> -- generic { revision, data } envelope (serde-ready)
  • InvalidationEvent -- { topic, revision } change notification (serde-ready)
  • compare_revisions() -- canonical u64 string comparison
# src-tauri/Cargo.toml
[dependencies]
state-sync = "0.1"
use state_sync::{InvalidationEvent, Revision, SnapshotEnvelope};

let rev = Revision::new(1);
let envelope = SnapshotEnvelope {
    revision: rev.to_string(),
    data: my_app_state,
};

The crate is versioned independently from the npm packages.

cd crates/state-sync
cargo test
cargo clippy --all-targets -- -D warnings

License

MIT (see LICENSE).