npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

syncraft

v0.1.2

Published

State that lives across time and tabs — sync engine for web apps

Readme

syncraft

State that lives across time and tabs.

A sync engine for modern web applications. State management, localStorage persistence, cross-tab synchronization, and a pub/sub event bus — all in one package, each module independently usable.

Installation · Quick Start · API Reference · Examples · FAQ


The problem

Building even a moderately complex web app means stitching together multiple libraries:

  • A state manager (zustand, jotai, redux)
  • A persistence plugin (redux-persist, zustand/middleware)
  • Cross-tab sync (roll your own BroadcastChannel)
  • A notification / event bus (mitt, EventEmitter3)

Each with its own config, its own types, its own upgrade cycle.

syncraft collapses all of that into a single, tree-shakeable package with zero runtime dependencies. You import only what you use. Everything composes naturally. Everything is typed.

// Before syncraft
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import mitt from 'mitt';
// + your own BroadcastChannel glue code

// After syncraft
import { createStore } from 'syncraft';
import { useSyncraft } from 'syncraft/react';
import { sync } from 'syncraft/sync';
import { events } from 'syncraft/events';

Features

| | | | ------------------------ | ------------------------------------------------------------------ | | useState replacement | useSyncraft works exactly like useState but persists and syncs | | Global store | createStore gives you a zustand-like store, usable from any file | | Cross-tab sync | BroadcastChannel with Safari fallback — just works, no config | | Persistence | localStorage or sessionStorage, with versioning and migration | | Event bus | Decoupled pub/sub for toasts, modals, analytics — in-page only | | Zero dependencies | No axios, no immer, no lodash — pure TypeScript | | Tree-shakeable | Use only syncraft/events and the rest won't ship in your bundle | | SSR safe | Next.js, Remix, Nuxt — all browser APIs are no-ops on the server | | React 18 concurrent | Built on useSyncExternalStore — no tearing, concurrent-mode safe | | Strict TypeScript | Full generics, inferred types, no any leaking out |


Installation

# npm
npm install syncraft

# pnpm
pnpm add syncraft

# yarn
yarn add syncraft

# bun
bun add syncraft

React peer dependency — only needed when importing from syncraft/react:

npm install react@">=18"

React is fully optional. The core, persist, sync and events modules have no peer dependencies at all.


Quick Start

React — drop-in useState replacement

import { useSyncraft } from 'syncraft/react';

function Counter() {
  const [count, setCount] = useSyncraft('counter', 0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((n) => n + 1)}>Increment</button>
    </div>
  );
}

That's it. The counter now:

  • Persists across page reloads
  • Stays in sync across all open browser tabs
  • Is shared between any component that uses the same key

Vanilla JS

import { createStore } from 'syncraft';

const counter = createStore('counter', 0);

counter.subscribe((value) => {
  document.getElementById('count').textContent = String(value);
});

document.getElementById('inc').onclick = () => counter.set((n) => n + 1);

Usage

syncraft provides six distinct APIs. Each lives in its own subpath so you only bundle what you use.

syncraft          → createStore, getStore, listStores, clearAll
syncraft/react    → useSyncraft, useStore
syncraft/persist  → persist
syncraft/sync     → sync
syncraft/events   → events

1. useSyncraft — useState with superpowers

Import from syncraft/react. Identical API to useState, but backed by a syncraft store.

import { useSyncraft } from 'syncraft/react';

Basic usage

function ThemeToggle() {
  const [theme, setTheme] = useSyncraft('theme', 'light');

  return (
    <button onClick={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))}>
      Current theme: {theme}
    </button>
  );
}

Functional updater

const [count, setCount] = useSyncraft('counter', 0);

setCount((prev) => prev + 1); // safe with stale closures
setCount(0); // or set directly

Shared state between components

Any two components using the same key receive the exact same state. No context provider needed.

// These two components are completely independent — no props, no context
function CartBadge() {
  const [cart] = useSyncraft('cart', []);
  return <span>{cart.length}</span>;
}

function CartDrawer() {
  const [cart, setCart] = useSyncraft('cart', []);
  return (
    <ul>
      {cart.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

Options

// Persist only (no cross-tab sync)
useSyncraft('draft', '', { sync: false });

// Cross-tab sync only (no localStorage)
useSyncraft('cursor', { x: 0, y: 0 }, { persist: false });

// Plain in-memory state — like vanilla useState but shared by key
useSyncraft('modal-open', false, { persist: false, sync: false });

// Use sessionStorage instead of localStorage
useSyncraft('wizard-step', 1, { storage: 'session' });

// Debounce storage writes (useful for text inputs)
useSyncraft('search-query', '', { debounce: 300 });

2. createStore + useStore — Global store

Create the store once, outside of any component, and consume it anywhere.

// src/stores/cart.ts
import { createStore } from 'syncraft';

export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export const cartStore = createStore<CartItem[]>('cart', []);

Actions pattern

Actions are just functions that call store.set(). No boilerplate, no reducers.

// src/stores/cart.ts (continued)
export const cartActions = {
  add(item: Omit<CartItem, 'quantity'>) {
    cartStore.set((prev) => {
      const existing = prev.find((p) => p.id === item.id);
      if (existing) {
        return prev.map((p) => (p.id === item.id ? { ...p, quantity: p.quantity + 1 } : p));
      }
      return [...prev, { ...item, quantity: 1 }];
    });
  },
  remove(id: string) {
    cartStore.set((prev) => prev.filter((p) => p.id !== id));
  },
  updateQuantity(id: string, quantity: number) {
    cartStore.set((prev) => prev.map((p) => (p.id === id ? { ...p, quantity } : p)));
  },
  clear() {
    cartStore.set([]);
  },
};

Consuming in React — full state

import { useStore } from 'syncraft/react';
import { cartStore, cartActions } from '../stores/cart';

function Cart() {
  const [cart, setCart] = useStore(cartStore);

  return (
    <div>
      {cart.map((item) => (
        <div key={item.id}>
          <span>{item.name}</span>
          <button onClick={() => cartActions.remove(item.id)}>Remove</button>
        </div>
      ))}
      <button onClick={cartActions.clear}>Clear cart</button>
    </div>
  );
}

Consuming with a selector — granular re-renders

When you pass a selector, the component only re-renders when the selected value changes. This is the key to performance.

// Re-renders only when cart.length changes — not on price updates etc.
function CartBadge() {
  const count = useStore(cartStore, (cart) => cart.length);
  return <span className="badge">{count}</span>;
}

// Re-renders only when total price changes
function CartTotal() {
  const total = useStore(cartStore, (cart) =>
    cart.reduce((sum, item) => sum + item.price * item.quantity, 0),
  );
  return <span>Total: ${total.toFixed(2)}</span>;
}

// Re-renders only when a specific item's quantity changes
function ItemQuantity({ id }: { id: string }) {
  const qty = useStore(cartStore, (cart) => cart.find((item) => item.id === id)?.quantity ?? 0);
  return <span>{qty}</span>;
}

3. createStore — Vanilla JS

No React required. The store works in plain JavaScript, in workers, in Node.js (SSR no-op).

import { createStore } from 'syncraft';

const userStore = createStore('user', {
  name: '',
  email: '',
  loggedIn: false,
});

Reading state

const current = userStore.get();
console.log(current.name); // ''

Writing state

// Direct value
userStore.set({ name: 'Ali', email: '[email protected]', loggedIn: true });

// Functional updater — safe with concurrent updates
userStore.set((prev) => ({ ...prev, loggedIn: false }));

Subscribing to changes

const unsubscribe = userStore.subscribe((newValue, oldValue) => {
  console.log('User changed:', oldValue, '→', newValue);
  updateUI(newValue);
});

// Later — clean up
unsubscribe();

Resetting to initial value

userStore.reset(); // sets back to { name: '', email: '', loggedIn: false }

Built-in event bus

Every store has its own event bus in addition to state subscribers.

// Listen to built-in 'change' event
userStore.on('change', ({ value, oldValue }) => {
  console.log('State changed via event bus');
});

// Custom events on the same store
userStore.on('validation-error', (errors) => showErrors(errors));
userStore.emit('validation-error', ['Name is required']);

// One-time listener
userStore.once('first-login', () => showWelcomeModal());

// Remove specific listener
const handler = (payload) => console.log(payload);
userStore.on('custom', handler);
userStore.off('custom', handler);

Destroying a store

// Clears all subscribers, closes sync channel, removes from registry
userStore.destroy();

Store configuration

const settingsStore = createStore(
  'settings',
  { language: 'en', notifications: true },
  {
    // Storage
    persist: true, // persist to storage (default: true)
    sync: true, // sync across tabs (default: true)
    storage: 'local', // 'local' | 'session' (default: 'local')

    // Custom serializer — use superjson for Date / Map / Set support
    serializer: {
      stringify: superjson.stringify,
      parse: superjson.parse,
    },

    // Schema migration
    version: 3,
    migrate: (oldData, oldVersion) => {
      if (oldVersion < 2) oldData = { ...oldData, theme: 'light' };
      if (oldVersion < 3) oldData = { ...oldData, language: oldData.locale ?? 'en' };
      return oldData;
    },

    // Performance
    debounce: 200, // batch storage writes (ms)
    equality: Object.is, // skip update if value hasn't changed
  },
);

4. persist — Storage adapter

A lightweight, SSR-safe, typed wrapper around localStorage and sessionStorage. No reactivity, no sync — just storage.

import { persist } from 'syncraft/persist';

Basic usage

const prefs = persist<{ language: string; notifications: boolean }>('user-prefs');

prefs.set({ language: 'az', notifications: true });

prefs.get(); // { language: 'az', notifications: true }
prefs.has(); // true
prefs.remove();
prefs.has(); // false
prefs.get(); // undefined

sessionStorage

const wizardState = persist<{ step: number; data: unknown }>('wizard', {
  storage: 'session',
});

wizardState.set({ step: 2, data: { name: 'Ali' } });
// Cleared automatically when the browser tab closes

Custom serializer

Use any serializer that handles complex types (Date, Map, Set, BigInt, etc.):

import superjson from 'superjson';

const richStorage = persist<{ createdAt: Date; tags: Set<string> }>('profile', {
  serializer: {
    stringify: superjson.stringify,
    parse: superjson.parse,
  },
});

richStorage.set({
  createdAt: new Date('2025-01-01'),
  tags: new Set(['admin', 'editor']),
});

richStorage.get();
// { createdAt: Date object, tags: Set object }  ← types preserved!

SSR safety

persist is completely safe to call on the server. All operations are no-ops when window is not available. No errors, no crashes.


5. sync — Cross-tab communication

Send messages between browser tabs. Does not store any state.

import { sync } from 'syncraft/sync';

Basic usage

interface AuthMessage {
  type: 'logout' | 'token-refresh' | 'session-expired';
  payload?: unknown;
}

const authChannel = sync<AuthMessage>('auth');

// Send a message to all OTHER open tabs
authChannel.send({ type: 'logout' });

// Listen for messages from other tabs
const unsubscribe = authChannel.on((message) => {
  console.log('Received from another tab:', message);
});

// Clean up
unsubscribe();
authChannel.close();

Multiple listeners

const notifications = sync<{ title: string; body: string }>('notifications');

notifications.on((msg) => updateBadge());
notifications.on((msg) => showDesktopNotification(msg));
notifications.on((msg) => logToAnalytics(msg));

BroadcastChannel fallback

syncraft automatically detects browser support and falls back gracefully:

  • Chrome, Edge, Firefox, Safari ≥ 15.4BroadcastChannel (direct, efficient)
  • Safari < 15.4localStorage storage events (automatic fallback)
  • Server (Node.js / SSR) → no-op (no errors)

You do nothing — the fallback is transparent.


6. events — In-page event bus

Decoupled pub/sub for communication within the same page. Does not cross tab boundaries (use sync for that).

import { events } from 'syncraft/events';

Basic usage

const bus = events();

// Subscribe
const unsubscribe = bus.on('user:login', (user) => {
  console.log('Logged in:', user);
});

// Publish
bus.emit('user:login', { id: 1, name: 'Ali' });

// Unsubscribe
unsubscribe();

One-time subscription

// Fires once, then automatically removes itself
bus.once('app:initialized', () => {
  console.log('App ready!');
  hideLoadingScreen();
});

Multiple listeners for the same event

bus.on('product:added', updateCartBadge);
bus.on('product:added', logAnalyticsEvent);
bus.on('product:added', showAddedToCartToast);

bus.emit('product:added', { id: 'shoe-42', name: 'Nike Air Max' });
// All three listeners fire

Clear all listeners

bus.clear(); // Remove every listener for every event

Real-world Examples

E-commerce cart — persist + sync + React

// src/stores/cart.ts
import { createStore } from 'syncraft';

export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
  imageUrl: string;
}

export const cartStore = createStore<CartItem[]>('cart', [], {
  persist: true, // Cart survives page refresh
  sync: true, // Cart updates across open tabs
});

export const cart = {
  add(item: Omit<CartItem, 'quantity'>) {
    cartStore.set((prev) => {
      const idx = prev.findIndex((p) => p.id === item.id);
      if (idx !== -1) {
        const next = [...prev];
        next[idx] = { ...next[idx], quantity: next[idx].quantity + 1 };
        return next;
      }
      return [...prev, { ...item, quantity: 1 }];
    });
  },
  remove: (id: string) => cartStore.set((prev) => prev.filter((p) => p.id !== id)),
  updateQty: (id: string, qty: number) =>
    cartStore.set((prev) =>
      prev.map((p) => (p.id === id ? { ...p, quantity: Math.max(1, qty) } : p)),
    ),
  clear: () => cartStore.set([]),
};
// src/components/CartIcon.tsx
import { useStore } from 'syncraft/react';
import { cartStore } from '../stores/cart';

export function CartIcon() {
  const count = useStore(cartStore, (items) => items.reduce((sum, i) => sum + i.quantity, 0));

  return (
    <button className="cart-icon">
      Cart
      {count > 0 && <span className="badge">{count}</span>}
    </button>
  );
}
// src/components/CartSidebar.tsx
import { useStore } from 'syncraft/react';
import { cartStore, cart } from '../stores/cart';

export function CartSidebar() {
  const [items] = useStore(cartStore);
  const total = useStore(cartStore, (items) => items.reduce((s, i) => s + i.price * i.quantity, 0));

  if (items.length === 0) return <p>Your cart is empty.</p>;

  return (
    <aside>
      {items.map((item) => (
        <div key={item.id}>
          <img src={item.imageUrl} alt={item.name} />
          <span>{item.name}</span>
          <input
            type="number"
            value={item.quantity}
            onChange={(e) => cart.updateQty(item.id, Number(e.target.value))}
          />
          <button onClick={() => cart.remove(item.id)}>Remove</button>
        </div>
      ))}
      <p>Total: ${total.toFixed(2)}</p>
      <button onClick={cart.clear}>Clear all</button>
    </aside>
  );
}

Multi-tab logout — sync

A classic pattern: user clicks logout in Tab A, all other tabs redirect to /login immediately.

// src/lib/auth.ts
import { sync } from 'syncraft/sync';
import { clearAll } from 'syncraft';

interface AuthEvent {
  type: 'logout' | 'session-expired';
}

const authBus = sync<AuthEvent>('auth');

export function logout() {
  // Clear local state
  localStorage.removeItem('access-token');
  clearAll(); // destroy all syncraft stores

  // Tell other tabs
  authBus.send({ type: 'logout' });

  // Redirect this tab
  window.location.href = '/login';
}

// Call this once at app startup (e.g. in your root component)
export function listenForRemoteLogout() {
  return authBus.on((event) => {
    if (event.type === 'logout' || event.type === 'session-expired') {
      window.location.href = '/login';
    }
  });
}
// src/App.tsx
import { useEffect } from 'react';
import { listenForRemoteLogout } from './lib/auth';

export function App() {
  useEffect(() => {
    const unsub = listenForRemoteLogout();
    return unsub;
  }, []);

  return <RouterProvider router={router} />;
}

Form draft auto-save — useSyncraft

Never lose a user's form input again. The draft is saved on every keystroke and restored on refresh.

// src/features/contact/ContactForm.tsx
import { useSyncraft } from 'syncraft/react';

interface ContactDraft {
  name: string;
  email: string;
  subject: string;
  message: string;
}

const EMPTY_DRAFT: ContactDraft = {
  name: '',
  email: '',
  subject: '',
  message: '',
};

export function ContactForm() {
  const [draft, setDraft] = useSyncraft<ContactDraft>('contact-draft', EMPTY_DRAFT, {
    debounce: 300, // don't hammer localStorage on every keystroke
    sync: false, // draft is personal to this user — no cross-tab sync needed
  });

  const update =
    <K extends keyof ContactDraft>(field: K) =>
    (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
      setDraft((prev) => ({ ...prev, [field]: e.target.value }));

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    await submitContactForm(draft);
    setDraft(EMPTY_DRAFT); // clear draft on success
  }

  return (
    <form onSubmit={handleSubmit}>
      <input placeholder="Your name" value={draft.name} onChange={update('name')} />
      <input type="email" placeholder="Your email" value={draft.email} onChange={update('email')} />
      <input placeholder="Subject" value={draft.subject} onChange={update('subject')} />
      <textarea
        placeholder="Your message"
        value={draft.message}
        onChange={update('message')}
        rows={6}
      />
      <button type="submit">Send message</button>
      <button type="button" onClick={() => setDraft(EMPTY_DRAFT)}>
        Discard draft
      </button>
    </form>
  );
}

Toast notification system — events

Decouple your toast UI from your business logic completely.

// src/lib/toast.ts
import { events } from 'syncraft/events';

export type ToastVariant = 'success' | 'error' | 'warning' | 'info';

export interface ToastMessage {
  id: string;
  message: string;
  variant: ToastVariant;
  duration?: number;
}

const bus = events();

export const toast = {
  success: (message: string, duration = 4000) =>
    bus.emit<ToastMessage>('toast', {
      id: crypto.randomUUID(),
      message,
      variant: 'success',
      duration,
    }),
  error: (message: string, duration = 6000) =>
    bus.emit<ToastMessage>('toast', {
      id: crypto.randomUUID(),
      message,
      variant: 'error',
      duration,
    }),
  warning: (message: string, duration = 5000) =>
    bus.emit<ToastMessage>('toast', {
      id: crypto.randomUUID(),
      message,
      variant: 'warning',
      duration,
    }),
  info: (message: string, duration = 4000) =>
    bus.emit<ToastMessage>('toast', {
      id: crypto.randomUUID(),
      message,
      variant: 'info',
      duration,
    }),
  // Subscribe to toast events (used by the Toaster component)
  onToast: (handler: (t: ToastMessage) => void) => bus.on('toast', handler),
};
// src/components/Toaster.tsx
import { useState, useEffect } from 'react';
import { toast, type ToastMessage } from '../lib/toast';

export function Toaster() {
  const [toasts, setToasts] = useState<ToastMessage[]>([]);

  useEffect(() => {
    return toast.onToast((newToast) => {
      setToasts((prev) => [...prev, newToast]);
      setTimeout(() => {
        setToasts((prev) => prev.filter((t) => t.id !== newToast.id));
      }, newToast.duration ?? 4000);
    });
  }, []);

  return (
    <div className="toaster" aria-live="polite">
      {toasts.map((t) => (
        <div key={t.id} className={`toast toast--${t.variant}`}>
          {t.message}
        </div>
      ))}
    </div>
  );
}
// Anywhere in your app — no imports of React context, no hooks required
import { toast } from '../lib/toast';

async function saveSettings(data: Settings) {
  try {
    await api.put('/settings', data);
    toast.success('Settings saved!');
  } catch {
    toast.error('Failed to save settings. Please try again.');
  }
}

Theme switcher — full integration

Combining createStore, useStore, events, and persist together.

// src/stores/theme.ts
import { createStore } from 'syncraft';
import { events } from 'syncraft/events';

export type Theme = 'light' | 'dark' | 'system';

export const themeStore = createStore<Theme>('theme', 'system');
export const themeEvents = events();

// Apply the theme to the document and fire a side-effect event
themeStore.subscribe((theme) => {
  const resolved =
    theme === 'system'
      ? window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark'
        : 'light'
      : theme;

  document.documentElement.setAttribute('data-theme', resolved);
  themeEvents.emit<Theme>('theme:applied', resolved);
});
// src/components/ThemeSwitcher.tsx
import { useStore } from 'syncraft/react';
import { themeStore } from '../stores/theme';
import type { Theme } from '../stores/theme';

const OPTIONS: { value: Theme; label: string }[] = [
  { value: 'light', label: 'Light' },
  { value: 'dark', label: 'Dark' },
  { value: 'system', label: 'System' },
];

export function ThemeSwitcher() {
  const [theme, setTheme] = useStore(themeStore);

  return (
    <div className="theme-switcher" role="group" aria-label="Color theme">
      {OPTIONS.map((opt) => (
        <button
          key={opt.value}
          onClick={() => setTheme(opt.value)}
          aria-pressed={theme === opt.value}
          className={theme === opt.value ? 'active' : undefined}
        >
          {opt.label}
        </button>
      ))}
    </div>
  );
}

TypeScript

Every API is fully typed. Generics are inferred from your initial values in most cases.

Inferred generics

// T is inferred as number
const counter = createStore('counter', 0);
counter.get(); // number
counter.set(1); // ✓
counter.set('hello'); // ✗ Type error

// T is inferred as { name: string; age: number }
const profile = createStore('profile', { name: '', age: 0 });
profile.set((prev) => ({ ...prev, age: prev.age + 1 })); // ✓

Explicit generics

type User = { id: number; name: string; role: 'admin' | 'user' };

const userStore = createStore<User | null>('user', null);
userStore.get(); // User | null
userStore.set({ id: 1, name: 'Ali', role: 'admin' }); // ✓
userStore.set({ id: 1, name: 'Ali' }); // ✗ missing 'role'

React hooks

// Inferred — no annotation needed
const [count, setCount] = useSyncraft('counter', 0);
// count: number
// setCount: (updater: number | ((prev: number) => number)) => void

// Explicit generic
const [user, setUser] = useSyncraft<User | null>('user', null);

Selector inference

// Return type of selector is automatically inferred
const name = useStore(userStore, (u) => u?.name);
// name: string | undefined

const isAdmin = useStore(userStore, (u) => u?.role === 'admin');
// isAdmin: boolean | undefined

persist and sync

const prefs = persist<{ lang: string; darkMode: boolean }>('prefs');
prefs.get(); // { lang: string; darkMode: boolean } | undefined
prefs.set({ lang: 'az', darkMode: true }); // ✓
prefs.set({ lang: 'az' }); // ✗ missing 'darkMode'

const channel = sync<{ type: 'ping' | 'pong' }>('heartbeat');
channel.send({ type: 'ping' }); // ✓
channel.send({ type: 'hello' }); // ✗ not assignable

events

interface AppEvents {
  'user:login': { id: number; name: string };
  'user:logout': undefined;
  toast: { message: string; variant: 'success' | 'error' };
}

const bus = events();

// Fully typed payload
bus.on<AppEvents['user:login']>('user:login', (user) => {
  console.log(user.name); // ✓ string
});

API Reference

createStore(key, initialValue, options?)

Creates a reactive, optionally persistent, optionally synced store. Registered in the global registry — calling createStore with the same key returns the existing instance.

Parameters:

| Parameter | Type | Description | | -------------- | ----------------- | ------------------------------------------------------- | | key | string | Unique identifier. Used as the localStorage key prefix. | | initialValue | T | Default state when no persisted value exists. | | options | StoreOptions<T> | Optional configuration (see below). |

StoreOptions:

| Option | Type | Default | Description | | ------------ | ---------------------- | ----------- | --------------------------------------- | | persist | boolean | true | Persist state to Web Storage. | | sync | boolean | true | Sync state across browser tabs. | | storage | 'local' \| 'session' | 'local' | Which Web Storage API to use. | | serializer | { stringify, parse } | JSON | Replace JSON serialization. | | version | number | — | Schema version for migration. | | migrate | (old, version) => T | — | Called when stored version is older. | | debounce | number | 0 | Delay storage writes by N milliseconds. | | equality | (a, b) => boolean | Object.is | Skip update when returns true. |

Store methods:

| Method | Signature | Description | | ----------------------- | ------------------------------------------------ | ------------------------------------------------------- | | get() | () => T | Return current state. | | set(updater) | (T \| ((prev: T) => T)) => void | Update state. Skipped if equality returns true. | | subscribe(fn) | (fn: (next: T, prev: T) => void) => () => void | Subscribe to state changes. Returns unsubscribe. | | reset() | () => void | Restore initial value. | | on(event, fn) | (event: string, fn) => () => void | Listen to a named event. Returns unsubscribe. | | off(event, fn) | (event: string, fn) => void | Remove specific event listener. | | emit(event, payload?) | (event: string, payload?: unknown) => void | Emit named event. | | once(event, fn) | (event: string, fn) => () => void | One-time event listener. Returns unsubscribe. | | destroy() | () => void | Remove from registry, clear subscribers, close channel. |


useSyncraft(key, initialValue, options?)

React hook. Returns [state, setState] — identical shape to useState.

| Parameter | Type | Description | | -------------- | ----------------- | ---------------------------------------------------- | | key | string | Store key. Components with the same key share state. | | initialValue | T | Default value. Ignored if store already exists. | | options | StoreOptions<T> | Same options as createStore. |

Returns: [T, (updater: T | ((prev: T) => T)) => void]

Built on useSyncExternalStore — concurrent-mode safe, no tearing.


useStore(store, selector?)

React hook for consuming a createStore instance.

Without selector — returns [state, setState]:

const [cart, setCart] = useStore(cartStore);

With selector — returns derived value only, no setState:

const count = useStore(cartStore, (cart) => cart.length);

Selector comparisons use Object.is. Return primitives or stable references to avoid unnecessary re-renders.


Registry helpers

import { getStore, listStores, clearAll } from 'syncraft';

| Function | Signature | Description | | --------------- | ---------------------------------------------- | -------------------------------------------------- | | getStore(key) | (key: string) => Store<unknown> \| undefined | Retrieve a store by key. | | listStores() | () => string[] | Return all active store keys. | | clearAll() | () => void | Destroy every registered store. Use during logout. |


persist(key, options?)

import { persist } from 'syncraft/persist';

| Method | Signature | Description | | ------------ | ---------------------- | ----------------------------------------------------------------- | | get() | () => T \| undefined | Read from storage. Returns undefined if not set or parse fails. | | set(value) | (value: T) => void | Write to storage. Warns on quota exceeded, never throws. | | has() | () => boolean | Returns true if key exists in storage. | | remove() | () => void | Delete the key from storage. |

PersistOptions:

| Option | Type | Default | Description | | ------------ | ---------------------- | --------- | ------------------------- | | storage | 'local' \| 'session' | 'local' | Which Web Storage to use. | | serializer | { stringify, parse } | JSON | Custom serializer. |


sync(channelName)

import { sync } from 'syncraft/sync';

| Method | Signature | Description | | ------------- | --------------------------------------- | ----------------------------------------------- | | send(data) | (data: T) => void | Send message to all other tabs on this channel. | | on(handler) | (fn: (data: T) => void) => () => void | Listen for messages. Returns unsubscribe. | | close() | () => void | Close the channel and remove all listeners. |

Messages are NOT received by the sending tab — only by other tabs.


events()

import { events } from 'syncraft/events';

| Method | Signature | Description | | ----------------------- | -------------------------------------- | ------------------------------------------- | | on(event, fn) | (event: string, fn) => () => void | Subscribe. Returns unsubscribe. | | off(event, fn) | (event: string, fn) => void | Remove specific listener. | | emit(event, payload?) | (event: string, payload?: T) => void | Fire event synchronously. | | once(event, fn) | (event: string, fn) => () => void | One-time subscription. Returns unsubscribe. | | clear() | () => void | Remove all listeners for all events. |


Comparison

| Feature | syncraft | zustand | jotai | valtio | redux-persist | | ------------------- | :----------: | :--------: | :----: | :----: | :-----------: | | Bundle (core, gzip) | ~3KB | ~3KB | ~3KB | ~3KB | ~5KB | | Zero dependencies | yes | yes | yes | no | no | | Cross-tab sync | built-in | plugin | — | — | — | | Persistence | built-in | middleware | plugin | — | yes | | Event bus | built-in | — | — | — | — | | React 18 concurrent | yes | yes | yes | yes | partial | | SSR safe | yes | yes | yes | yes | partial | | Framework agnostic | yes | yes | yes | yes | no | | Vanilla JS API | yes | no | no | yes | no | | TypeScript | strict | good | good | good | good | | Storage versioning | yes | manual | manual | manual | yes | | Selector support | yes | yes | yes | yes | no |


Architecture

createStore('key', value, options)
        │
        ├── events()          ← always: built-in event bus per store
        │
        ├── persist()         ← when persist: true
        │     └── localStorage / sessionStorage
        │
        └── sync()            ← when sync: true
              ├── BroadcastChannel   (modern browsers)
              └── storage events     (Safari < 15.4 fallback)

Cross-tab loop prevention: When Tab A sets a value, it broadcasts to Tab B. Tab B receives the broadcast and updates its local state — but does NOT re-broadcast. This is enforced with a isReceivingFromBroadcast flag inside each store closure.

No deep cloning: syncraft never deep-clones your state. When you call set(), you are responsible for providing a new reference (immutable update pattern). This keeps the library fast and predictable.

Registry: All stores are held in a module-level Map<string, Store>. Calling createStore with a key that already exists returns the existing store and logs a warning in development. This makes it safe to call createStore outside of React components (e.g. in a module-level store file).


Integration guides

Next.js (App Router)

// app/providers.tsx
'use client';

import { useEffect } from 'react';
import { listenForRemoteLogout } from '../lib/auth';

export function Providers({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    // Only runs in the browser — no SSR issues
    const unsub = listenForRemoteLogout();
    return unsub;
  }, []);

  return <>{children}</>;
}
// lib/stores/user.ts
import { createStore } from 'syncraft';

// Safe to import in Server Components — createStore is SSR-safe
// (browser APIs are no-ops on the server)
export const userStore = createStore<User | null>('user', null, {
  persist: true,
  sync: true,
});

Vite + React

Works out of the box — no configuration needed.

Remix

// app/root.tsx
import { clearAll } from 'syncraft';

export async function action({ request }: ActionArgs) {
  // Server-side: clearAll() is a no-op, safe to call
  await logout(request);
  return redirect('/login');
}
// app/root.tsx (client)
import { useEffect } from 'react';
import { listenForRemoteLogout } from '~/lib/auth';

export default function App() {
  useEffect(() => listenForRemoteLogout(), []);
  return <Outlet />;
}

Browser support

| Environment | Status | Notes | | -------------- | ------- | ------------------------------------------- | | Chrome 54+ | Full | BroadcastChannel | | Edge 79+ | Full | BroadcastChannel | | Firefox 38+ | Full | BroadcastChannel | | Safari 15.4+ | Full | BroadcastChannel | | Safari < 15.4 | Full | Automatic storage-event fallback | | iOS Safari | Full | Same as Safari desktop | | Android Chrome | Full | BroadcastChannel | | Node.js / SSR | No-op | All browser APIs guarded, never throws | | Web Workers | Partial | Core + sync work; persist requires polyfill |


Schema migration

When your stored data shape changes between versions, use version + migrate:

// v1 schema: { name: string }
// v2 schema: { firstName: string; lastName: string }
// v3 schema: { firstName: string; lastName: string; displayName: string }

const profileStore = createStore(
  'profile',
  {
    firstName: '',
    lastName: '',
    displayName: '',
  },
  {
    version: 3,
    migrate: (oldData: unknown, oldVersion: number) => {
      let data = oldData as Record<string, unknown>;

      if (oldVersion < 2) {
        // Split name into firstName + lastName
        const [firstName = '', ...rest] = String(data.name ?? '').split(' ');
        data = { firstName, lastName: rest.join(' ') };
      }

      if (oldVersion < 3) {
        // Add displayName
        data = { ...data, displayName: `${data.firstName} ${data.lastName}`.trim() };
      }

      return data as { firstName: string; lastName: string; displayName: string };
    },
  },
);

Migration runs automatically when the stored version is older than the current version. The migrated data is written back to storage immediately.


FAQ

Does syncraft work without React?

Yes. createStore, persist, sync, and events have zero React dependency. They work in plain JavaScript, TypeScript, Vue (via subscribe), Svelte stores, Angular services — anywhere.

Is it SSR safe? Does it work with Next.js?

Yes. Every browser API access (localStorage, BroadcastChannel, window) is guarded with typeof window !== 'undefined'. On the server, all operations become no-ops — no exceptions, no crashes, no hydration mismatches.

What happens when two components use the same key with different initial values?

The first call to createStore with a given key wins. If a second call uses a different initial value, a warning is logged in development and the existing store is returned. Always use the same initial value across all usages of a key.

Can I use it with React Query / TanStack Query?

Yes, they solve different problems. syncraft manages client-side state (UI state, preferences, cart). React Query manages server state (API responses, cache invalidation). They compose cleanly.

Does the sync work between different domains?

No. BroadcastChannel and localStorage are same-origin only. Cross-origin communication requires a server (WebSocket, SSE).

How do I handle initial data from an API?

const userStore = createStore<User | null>('user', null, { sync: false });

// After API response
const user = await api.get('/me');
userStore.set(user);

How do I prevent a store from persisting sensitive data?

// Disable persistence entirely
const tokenStore = createStore('token', null, { persist: false, sync: false });

Or use sessionStorage so data is cleared when the tab closes:

const sessionStore = createStore('session', null, { storage: 'session' });

How do I reset all state during logout?

import { clearAll } from 'syncraft';

function logout() {
  clearAll(); // destroys every registered store
  localStorage.removeItem('token');
  window.location.href = '/login';
}

Is there a DevTools extension?

Not yet. Planned for v0.4. In the meantime, use listStores() and getStore() in the browser console:

// In browser DevTools console:
import { listStores, getStore } from 'syncraft';
listStores(); // ['cart', 'user', 'theme']
getStore('cart').get(); // [{ id: '1', name: 'Shoe', ... }]

Can I use custom storage backends (IndexedDB, cookies)?

Custom serializers are supported today. Custom storage backends (IndexedDB) are planned for v0.3. You can implement your own adapter in the meantime using createStore with persist: false and subscribing manually.

Does syncraft work in a Web Worker?

The core store and events bus work. sync (BroadcastChannel) also works in workers. persist needs localStorage which is not available in Workers by default.


Roadmap

| Version | Status | Features | | -------- | ----------- | ---------------------------------------------------------- | | v0.1 | current | Core store, persist, sync, events, React adapter, SSR safe | | v0.2 | planned | Vue 3 and Svelte adapters | | v0.3 | planned | IndexedDB adapter, cookie adapter | | v0.4 | planned | Browser DevTools extension | | v1.0 | future | Stable API, LTS guarantee | | v2.0 | far future | Server sync via WebSocket, CRDT-based conflict resolution |


Contributing

See CONTRIBUTING.md for detailed guidelines.

Quick start:

git clone https://github.com/hesenv07/syncraft.git
cd syncraft
npm install
npm run build      # compile the package
npm run type-check # TypeScript strict check
npm run lint       # ESLint

Commit convention — this project uses Conventional Commits:

feat: add Vue adapter
fix: prevent cross-tab loop when sync: true
docs: improve persist API reference
chore: upgrade tsup to v9

License

MIT © 2026 syncraft contributors