syncraft
v0.1.2
Published
State that lives across time and tabs — sync engine for web apps
Maintainers
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 syncraftReact 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 → events1. 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 directlyShared 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(); // undefinedsessionStorage
const wizardState = persist<{ step: number; data: unknown }>('wizard', {
storage: 'session',
});
wizardState.set({ step: 2, data: { name: 'Ali' } });
// Cleared automatically when the browser tab closesCustom 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.4 →
BroadcastChannel(direct, efficient) - Safari < 15.4 →
localStoragestorage 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 fireClear all listeners
bus.clear(); // Remove every listener for every eventReal-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 | undefinedpersist 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 assignableevents
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 # ESLintCommit 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 v9License
MIT © 2026 syncraft contributors
