shared-state-bridge
v1.0.2
Published
Share React state across packages in Turborepo, Nx, and Lerna monorepos. TypeScript-first cross-package state store with React 18 hooks, WebSocket real-time sync, and localStorage/AsyncStorage persistence. Zero dependencies, ~1.2 KB gzipped.
Maintainers
Readme
Share React State Across Packages in Turborepo & Nx Monorepos | shared-state-bridge
Share state between packages in a React monorepo without prop drilling, context threading, or Redux boilerplate. shared-state-bridge is a cross-package state store for Turborepo, Nx, and Lerna monorepos -- with React 18 hooks, WebSocket real-time sync, and optional localStorage/AsyncStorage persistence.
Monorepo state management for React -- create a named store in one package, access it by name from any other. ~1.2 KB core, zero dependencies, full TypeScript inference.
- Share React state across packages -- named bridges resolve via a global registry, accessible from any package in your monorepo
- React 18 hooks with selector optimization --
useSyncExternalStorewith fine-grained re-render control - Persist state to localStorage or AsyncStorage -- built-in adapters for web and React Native, or write your own
- Real-time WebSocket state sync between apps -- sync state across Next.js, React Native, and any other connected client
- ~1.2 KB gzipped core, zero dependencies -- tree-shakeable entry points for React hooks, persistence, and sync
- Full TypeScript inference and safety -- typed
setState, typed selectors, typed plugins - Extensible plugin system -- persistence, sync, logging, validation, or your own custom plugins
Why Use a Cross-Package State Store in a Monorepo?
In Turborepo, Nx, and Lerna monorepos, multiple packages often need to share runtime state -- authentication tokens, user preferences, feature flags, real-time collaboration data. The typical workarounds all have costs: lifting state into a shared package creates tight coupling, duplicating stores across packages leads to drift, and wiring up Redux or Zustand across package boundaries requires manual plumbing that breaks as the monorepo scales.
shared-state-bridge solves this with a named global registry. Call createBridge('auth', initialState) in one package, call getBridge('auth') from any other -- same store instance, same state, no imports needed beyond the type. The registry uses Symbol.for internally, so it works even when multiple copies of the library exist in node_modules (a common monorepo pitfall).
How shared-state-bridge Compares
| Feature | shared-state-bridge | Zustand | Redux Toolkit | Jotai |
|---|---|---|---|---|
| Cross-package registry | Yes (named bridges) | No | No | No |
| Monorepo / Turborepo / Nx | First-class | Manual wiring | Manual wiring | Manual wiring |
| React 18 useSyncExternalStore | Yes | Yes | Yes | Yes |
| Real-time WebSocket sync | Built-in plugin | No | No | No |
| Persistence plugin | Built-in | Middleware | Redux Persist | No |
| Bundle size (full) | ~5.1 KB gzipped | ~1 KB | ~11 KB | ~3 KB |
| TypeScript-first | Yes | Yes | Yes | Yes |
| Dependencies | Zero | Zero | Several | Zero |
vs Zustand: Zustand is a general-purpose state manager for a single package. shared-state-bridge adds a named global registry so any package in the monorepo can call getBridge('app') and get the same store instance — no prop drilling, no manual exports. If you only need state within one package, Zustand is a great choice.
vs Redux / Redux Toolkit: Redux works well for large apps with complex update logic. shared-state-bridge trades Redux's reducer boilerplate for a simpler setState API, and adds cross-package and WebSocket sync out of the box at a fraction of the bundle cost.
vs Jotai: Jotai's atom model is elegant for fine-grained reactivity within a package. shared-state-bridge targets a different problem: sharing a named, structured state object across package boundaries in a monorepo, with optional real-time sync.
Table of Contents
- Install
- Quick Start
- Core Concepts
- Cross-Package Sharing
- React Integration
- Persistence
- Real-Time Sync (WebSocket)
- Plugins
- TypeScript
- Monorepo Setup (Turborepo / Nx)
- API Reference
- Architecture Deep Dive
- Gotchas and Common Pitfalls
- FAQ
- Support
- License
Install
# npm
npm install shared-state-bridge
# yarn
yarn add shared-state-bridge
# pnpm
pnpm add shared-state-bridgeNote: React is an optional peer dependency. If you only use the core API (no hooks), React is not required.
Quick Start
import { createBridge } from 'shared-state-bridge'
const bridge = createBridge({
name: 'app',
initialState: { count: 0, theme: 'light' },
})
// Read state
bridge.getState() // { count: 0, theme: 'light' }
// Update state (partial merge)
bridge.setState({ count: 1 })
// Update with updater function
bridge.setState(prev => ({ count: prev.count + 1 }))
// Subscribe to changes
const unsub = bridge.subscribe((state, prev) => {
console.log('Changed:', state)
})
// Unsubscribe when done
unsub()With React
import { BridgeProvider, useBridgeState, useBridge } from 'shared-state-bridge/react'
function App() {
return (
<BridgeProvider bridge={bridge}>
<Counter />
</BridgeProvider>
)
}
function Counter() {
const count = useBridgeState<AppState, number>(s => s.count)
const bridge = useBridge<AppState>()
return (
<button onClick={() => bridge.setState(s => ({ count: s.count + 1 }))}>
Count: {count}
</button>
)
}With Persistence
import { createBridge } from 'shared-state-bridge'
import { persist, localStorageAdapter } from 'shared-state-bridge/persist'
const bridge = createBridge({
name: 'app',
initialState: { theme: 'light', count: 0 },
plugins: [
persist({
adapter: localStorageAdapter,
key: 'app-state',
pick: ['theme'], // only persist theme
}),
],
})Core Concepts
Creating a Bridge
Every bridge has a unique name that registers it in a global registry. This name is how other packages in your monorepo can access the same bridge instance.
import { createBridge } from 'shared-state-bridge'
interface AppState {
user: { name: string; email: string } | null
theme: 'light' | 'dark'
notifications: number
}
const appBridge = createBridge<AppState>({
name: 'app', // unique identifier
initialState: { // required initial state
user: null,
theme: 'light',
notifications: 0,
},
plugins: [], // optional plugins array
})Key rules:
- The
namemust be unique across your entire application. Attempting to create two bridges with the same name throws an error. initialStatemust be a plain object (not an array, not a primitive).- The bridge is immediately registered in the global registry upon creation.
Reading and Updating State
getState()
Returns the current state snapshot. This is a synchronous read.
const state = appBridge.getState()
console.log(state.theme) // 'light'setState(partial) — Partial Merge
By default, setState performs a shallow merge with the current state (like React's useState with objects). Only the keys you provide are updated; other keys are preserved.
appBridge.setState({ theme: 'dark' })
// State is now: { user: null, theme: 'dark', notifications: 0 }
// ^^^^^ changed
// rest preservedsetState(updater) — Updater Function
Pass a function to compute the next state based on the current state. Useful when the new value depends on the old one.
appBridge.setState(prev => ({
notifications: prev.notifications + 1,
}))setState(state, true) — Full Replacement
Pass true as the second argument to replace the entire state instead of merging.
appBridge.setState(
{ user: null, theme: 'light', notifications: 0 },
true // replaces entirely — no merge
)Important: After replacement, any keys not included in the new state are gone. Use this carefully.
getInitialState()
Returns the original initial state that was passed to createBridge. This never changes, even after setState calls.
appBridge.setState({ notifications: 99 })
appBridge.getInitialState() // { user: null, theme: 'light', notifications: 0 }
appBridge.getState() // { user: null, theme: 'light', notifications: 99 }Subscribing to Changes
Full State Subscription
Listen to every state change:
const unsub = appBridge.subscribe((state, previousState) => {
console.log('State changed:', state)
console.log('Previous:', previousState)
})
// Later: stop listening
unsub()Behavior:
- The listener is called synchronously after each
setStatethat produces a new state reference. - If
setStateis called but the state reference doesn't change (e.g., replacing with the same object viasetState(sameRef, true)), listeners are not called. - Multiple listeners fire in the order they were subscribed.
Selector-Based Subscription
Listen only when a specific slice of state changes:
const unsub = appBridge.subscribe(
state => state.theme, // selector
(theme, previousTheme) => { // only called when theme changes
console.log(`Theme: ${previousTheme} -> ${theme}`)
}
)
appBridge.setState({ notifications: 5 }) // listener NOT called (theme unchanged)
appBridge.setState({ theme: 'dark' }) // listener called: 'light' -> 'dark'Subscription Options
appBridge.subscribe(
state => state.notifications,
(count, prevCount) => updateBadge(count),
{
// Fire the listener immediately with the current value
fireImmediately: true,
// Custom equality function (default: Object.is)
equalityFn: (a, b) => Math.abs(a - b) < 5,
}
)| Option | Type | Default | Description |
|---|---|---|---|
| fireImmediately | boolean | false | Call the listener once immediately with the current value |
| equalityFn | (a, b) => boolean | Object.is | Custom comparison to determine if the slice changed |
Destroying a Bridge
appBridge.destroy()What destroy() does:
- Removes the bridge from the global registry (it can no longer be found via
getBridge) - Calls
onDestroyon all plugins - Clears all listeners (no more notifications)
- Marks the bridge as destroyed — subsequent
setState/subscribecalls are silent no-ops
When to use it:
- In tests, to clean up between test cases
- When unmounting a micro-frontend
- Before re-creating a bridge with the same name
// Re-creating a bridge after destroy
appBridge.destroy()
const newBridge = createBridge({ name: 'app', initialState: { ... } }) // OKCross-Package Sharing
This is the key feature of shared-state-bridge. In a monorepo, all packages are bundled into the same application and share the same JavaScript runtime. Bridges leverage this by storing instances in a global registry that any package can access.
Package A — Creates the Bridge
// packages/shared/src/bridges.ts
import { createBridge } from 'shared-state-bridge'
export interface AuthState {
user: { id: string; name: string } | null
token: string | null
}
export const authBridge = createBridge<AuthState>({
name: 'auth',
initialState: { user: null, token: null },
})Package B — Accesses the Bridge by Name
// packages/dashboard/src/auth.ts
import { getBridge } from 'shared-state-bridge'
import type { AuthState } from '@myorg/shared'
// Returns the EXACT same instance created in Package A
const authBridge = getBridge<AuthState>('auth')
authBridge.subscribe(
state => state.user,
(user) => {
if (!user) redirectToLogin()
}
)Registry Utilities
import { getBridge, hasBridge, listBridges } from 'shared-state-bridge'
// Check existence without throwing
if (hasBridge('auth')) {
const auth = getBridge('auth')
}
// List all registered bridges (useful for debugging)
console.log(listBridges()) // ['auth', 'app', 'ui']How It Works (Architecture)
┌─────────────────────────────────────────────────────────────┐
│ JavaScript Runtime │
│ │
│ globalThis[Symbol.for('shared-state-bridge.registry')] │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Map<string, BridgeApi> │ │
│ │ │ │
│ │ 'auth' -> BridgeApi { state, listeners, ... } │ │
│ │ 'app' -> BridgeApi { state, listeners, ... } │ │
│ │ 'ui' -> BridgeApi { state, listeners, ... } │ │
│ └───────────────────────────────────────────────────┘ │
│ ^ ^ │
│ | | │
│ ┌──────────────┴──┐ ┌──────────┴───────────┐ │
│ │ Package A │ │ Package B │ │
│ │ │ │ │ │
│ │ createBridge() │ │ getBridge('auth') │ │
│ │ registers here │ │ reads from here │ │
│ └─────────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────┘The registry uses Symbol.for('shared-state-bridge.registry') as the key on globalThis. Why Symbol.for() instead of a plain string?
Symbol.for('x')returns the same symbol across all modules, all files, and even across multiple copies of the package- Even if your monorepo accidentally has two versions of
shared-state-bridgeinnode_modules(a common pitfall), both versions use the sameSymbol.for()key and access the sameMap - A plain string key like
globalThis.__shared_state_bridge__could collide with other libraries; symbols cannot
React Integration
The React bindings are imported separately from shared-state-bridge/react and have React 18+ as an optional peer dependency.
BridgeProvider
Provides a bridge instance to the React component tree via context.
import { createBridge } from 'shared-state-bridge'
import { BridgeProvider } from 'shared-state-bridge/react'
const bridge = createBridge({
name: 'app',
initialState: { count: 0, theme: 'light' },
})
function App() {
return (
<BridgeProvider bridge={bridge}>
<YourApp />
</BridgeProvider>
)
}Props:
| Prop | Type | Description |
|---|---|---|
| bridge | BridgeApi<T> | The bridge instance to provide |
| children | React.ReactNode | Child components |
Note:
BridgeProvideris optional. You can pass bridges directly to hooks if you prefer.
useBridgeState
The primary hook for reading bridge state in React components. Uses useSyncExternalStore internally for tear-free, concurrent-safe reads.
Signature 1: From Context
function useBridgeState<T extends State, U>(
selector: (state: T) => U,
options?: { shallow?: boolean }
): URequires a BridgeProvider ancestor:
function ThemeToggle() {
const theme = useBridgeState<AppState, string>(s => s.theme)
// Only re-renders when theme changes
}Signature 2: Direct Bridge Reference
function useBridgeState<T extends State, U>(
bridge: BridgeApi<T>,
selector: (state: T) => U,
options?: { shallow?: boolean }
): UNo provider needed:
function Counter() {
const count = useBridgeState(bridge, s => s.count)
// Only re-renders when count changes
}Signature 3: Full State (No Selector)
function useBridgeState<T extends State>(
bridge: BridgeApi<T>
): TReturns the entire state object:
function Debug() {
const state = useBridgeState(bridge)
// Re-renders on EVERY state change
return <pre>{JSON.stringify(state, null, 2)}</pre>
}useBridge
Returns the bridge instance from the nearest BridgeProvider. Use this for imperative operations like setState.
import { useBridge } from 'shared-state-bridge/react'
function LogoutButton() {
const bridge = useBridge<AuthState>()
return (
<button onClick={() => bridge.setState({ user: null, token: null })}>
Log Out
</button>
)
}Throws if used outside a BridgeProvider.
Preventing Unnecessary Re-renders
Primitive Selectors (No Problem)
Selectors that return primitives (strings, numbers, booleans) work perfectly with the default Object.is comparison:
const count = useBridgeState(bridge, s => s.count) // number
const theme = useBridgeState(bridge, s => s.theme) // string
// These only re-render when the VALUE actually changesObject Selectors (Use { shallow: true })
Selectors that return new objects create a new reference on every call, causing unnecessary re-renders:
// BAD: Creates a new object every render -> re-renders on EVERY state change
const user = useBridgeState(bridge, s => ({
name: s.user?.name,
email: s.user?.email,
}))
// GOOD: Shallow comparison prevents re-render when values haven't changed
const user = useBridgeState(
bridge,
s => ({ name: s.user?.name, email: s.user?.email }),
{ shallow: true }
)How { shallow: true } works internally:
- The hook computes the new selector result
- It compares each key/value with the previous result using
Object.is - If all keys and values match, it returns the previous object reference
- React's
useSyncExternalStoresees the same reference and skips re-rendering
Alternative: Select Primitives Individually
If you only need a few values, separate selectors can be simpler:
// Each hook only re-renders when its specific value changes
const name = useBridgeState(bridge, s => s.user?.name)
const email = useBridgeState(bridge, s => s.user?.email)Server-Side Rendering (Next.js)
useBridgeState is SSR-safe. During server rendering, it uses getInitialState() as the server snapshot, preventing hydration mismatches.
// This works in Next.js App Router and Pages Router
function ThemeSwitcher() {
const theme = useBridgeState(bridge, s => s.theme)
// Server: returns initial state ('light')
// Client: returns current state (may differ after hydration)
}Caveat: On the server in development mode, globalThis persists across requests due to hot module reload. For production this is not an issue since each request gets a fresh runtime. If you encounter stale state in dev, ensure bridges are created in module scope or call destroy() in cleanup.
Persistence
Persistence is opt-in via the persist plugin from shared-state-bridge/persist. State is serialized and written to a storage adapter on every change (throttled). On initialization, persisted state is hydrated back into the bridge.
Web — localStorage
import { createBridge } from 'shared-state-bridge'
import { persist, localStorageAdapter } from 'shared-state-bridge/persist'
const bridge = createBridge({
name: 'app',
initialState: { theme: 'light', count: 0 },
plugins: [
persist({
adapter: localStorageAdapter,
key: 'app-state',
pick: ['theme'], // only persist theme, not count
}),
],
})The localStorageAdapter is safe to use in SSR environments — it silently returns null when localStorage is unavailable.
React Native — AsyncStorage
The asyncStorageAdapter is a factory function that accepts an AsyncStorage instance. This avoids a hard dependency on @react-native-async-storage/async-storage.
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createBridge } from 'shared-state-bridge'
import { persist, asyncStorageAdapter } from 'shared-state-bridge/persist'
const bridge = createBridge({
name: 'app',
initialState: { theme: 'light' },
plugins: [
persist({
adapter: asyncStorageAdapter(AsyncStorage),
key: 'app-state',
}),
],
})Custom Adapter
Implement the PersistAdapter interface with 3 methods. Each method can return a value synchronously or a Promise:
import type { PersistAdapter } from 'shared-state-bridge'
const customAdapter: PersistAdapter = {
getItem: (key: string) => string | null | Promise<string | null>
setItem: (key: string, value: string) => void | Promise<void>
removeItem: (key: string) => void | Promise<void>
}Example — IndexedDB adapter:
const idbAdapter: PersistAdapter = {
getItem: async (key) => {
const db = await openDB()
return db.get('state-store', key)
},
setItem: async (key, value) => {
const db = await openDB()
await db.put('state-store', value, key)
},
removeItem: async (key) => {
const db = await openDB()
await db.delete('state-store', key)
},
}Persistence Options Reference
persist({
// REQUIRED
adapter: PersistAdapter, // Storage backend
key: string, // Storage key name
// FILTERING (pick one or neither)
pick?: (keyof T)[], // Only persist these keys
omit?: (keyof T)[], // Exclude these keys
// PERFORMANCE
throttleMs?: number, // Throttle writes (default: 100ms)
// SERIALIZATION
serialize?: (state) => string, // Custom serializer (default: JSON.stringify)
deserialize?: (raw) => unknown, // Custom deserializer (default: JSON.parse)
// VERSIONING
version?: number, // Schema version (default: 0)
migrate?: (persisted, oldVersion) => Partial<T>, // Migration function
})| Option | Type | Default | Description |
|---|---|---|---|
| adapter | PersistAdapter | required | The storage backend to use |
| key | string | required | Key under which state is stored |
| pick | (keyof T)[] | undefined | Whitelist: only persist these state keys |
| omit | (keyof T)[] | undefined | Blacklist: exclude these state keys from persistence |
| throttleMs | number | 100 | Minimum interval between writes (ms). Prevents excessive writes during rapid state changes. Trailing writes are guaranteed. |
| serialize | (state) => string | JSON.stringify | Custom serialization function |
| deserialize | (raw) => unknown | JSON.parse | Custom deserialization function |
| version | number | 0 | Schema version number, stored alongside persisted state |
| migrate | (persisted, oldVersion) => Partial<T> | undefined | Called when persisted version differs from current version |
Schema Migrations
When your state shape changes between app versions, use version and migrate to handle the transition:
// Version 1 state: { theme: 'light' }
// Version 2 state: { theme: 'light', locale: 'en' }
const bridge = createBridge({
name: 'app',
initialState: { theme: 'light', locale: 'en' },
plugins: [
persist({
adapter: localStorageAdapter,
key: 'app-state',
version: 2,
migrate: (persisted, oldVersion) => {
if (oldVersion === 1) {
// Add the new 'locale' field with a default value
return { ...(persisted as object), locale: 'en' } as Partial<AppState>
}
return persisted as Partial<AppState>
},
}),
],
})Migration behavior:
- If persisted version matches current version: hydrate normally
- If versions differ AND
migrateis provided: callmigrate(persisted, oldVersion)and use the result - If versions differ AND
migrateis NOT provided: discard persisted data entirely and start fresh
Storage Envelope Format
The persist plugin stores data in this format:
{
"state": { "theme": "dark", "locale": "en" },
"version": 2
}Testing with memoryAdapter
The memoryAdapter() factory creates an in-memory storage backend. Perfect for tests and SSR:
import { memoryAdapter } from 'shared-state-bridge/persist'
// Each call creates a fresh, isolated store
const adapter = memoryAdapter()
const bridge = createBridge({
name: 'test',
initialState: { count: 0 },
plugins: [persist({ adapter, key: 'test' })],
})Real-Time Sync (WebSocket)
The sync plugin enables real-time state synchronization between different apps over WebSocket. Connect your Next.js web app, React Native mobile app, and any other client to the same channel — state changes propagate instantly.
Important: This is a client-side plugin only. You provide your own WebSocket server URL. A minimal example server is included below.
Basic Usage
import { createBridge } from 'shared-state-bridge'
import { sync } from 'shared-state-bridge/sync'
const bridge = createBridge({
name: 'app',
initialState: { theme: 'light', count: 0, localDraft: '' },
plugins: [
sync({
url: 'wss://your-server.com/sync',
channel: 'room-123',
}),
],
})
// State changes are now synced across all connected clients
bridge.setState({ theme: 'dark' }) // -> sent to all other clients in room-123How It Works
App A (Next.js) WebSocket Server App B (React Native)
───────────────── ────────────────── ─────────────────────
bridge.setState()
│
├─ onStateChange()
│ ├─ isApplyingRemote? skip (echo guard)
│ └─ filterState() ──► send({ type: "state" }) ──► broadcast to channel
│ │ │
│ │ ◄──────┘
│ │ onMessage()
│ │ ├─ same clientId? skip
│ │ ├─ resolve(local, remote)
│ │ └─ bridge.setState(merged)
│ │ └─ isApplyingRemote = true
│ │ (prevents re-broadcast)Each client gets a unique clientId. Messages are tagged with this ID so clients ignore their own echoes. An isApplyingRemote flag prevents re-broadcasting state updates received from the server.
Selective Sync (pick / omit)
Same as persistence — only sync the keys you need:
sync({
url: 'wss://your-server.com/sync',
channel: 'room-1',
pick: ['theme', 'count'], // only sync these keys
})
// OR
sync({
url: 'wss://your-server.com/sync',
channel: 'room-1',
omit: ['localDraft'], // sync everything except these
})Custom Conflict Resolution
By default, incoming remote state is merged directly (last-write-wins). You can provide a custom resolve function:
sync({
url: 'wss://your-server.com/sync',
channel: 'room-1',
resolve: (localState, remoteState) => {
// Custom logic: take the higher count, but always accept remote theme
return {
count: Math.max(localState.count, remoteState.count ?? 0),
theme: remoteState.theme ?? localState.theme,
}
},
})The resolve function receives the full local state and the incoming remote partial state, and should return the partial state to apply.
Reconnection
Auto-reconnect is enabled by default with exponential backoff:
sync({
url: 'wss://your-server.com/sync',
channel: 'room-1',
reconnect: true, // default: true
reconnectInterval: 1000, // base interval in ms (default: 1000)
maxReconnectInterval: 30000, // cap in ms (default: 30000)
maxReconnectAttempts: Infinity, // default: Infinity
})The backoff sequence is: 1s, 2s, 4s, 8s, 16s, 30s, 30s, 30s... The counter resets on every successful connection. Messages sent while disconnected are buffered and flushed on reconnect.
Callbacks
sync({
url: 'wss://your-server.com/sync',
channel: 'room-1',
onConnect: () => console.log('Connected to sync server'),
onDisconnect: () => console.log('Disconnected from sync server'),
onError: (error) => console.error('Sync error:', error),
})Sync Options Reference
| Option | Type | Default | Description |
|---|---|---|---|
| url | string | required | WebSocket server URL (wss://...) |
| channel | string | required | Channel/room name to join |
| pick | (keyof T)[] | — | Only sync these keys |
| omit | (keyof T)[] | — | Exclude these keys from sync |
| throttleMs | number | 50 | Throttle outbound messages (ms) |
| reconnect | boolean | true | Auto-reconnect on disconnect |
| reconnectInterval | number | 1000 | Base reconnect interval (ms) |
| maxReconnectInterval | number | 30000 | Max reconnect interval cap (ms) |
| maxReconnectAttempts | number | Infinity | Max reconnect attempts |
| onConnect | () => void | — | Called on successful connection |
| onDisconnect | () => void | — | Called on disconnection |
| onError | (error) => void | — | Called on WebSocket error |
| resolve | (local, remote) => Partial<T> | — | Custom conflict resolver |
Wire Protocol
All messages are JSON over WebSocket. The protocol is simple and easy to implement on any server.
Client -> Server:
// Join a channel
{ type: "join", channel: "room-1", clientId: "abc123" }
// Send state update
{ type: "state", channel: "room-1", clientId: "abc123", state: { count: 5 }, timestamp: 1700000000000 }Server -> Client:
// Relay state from another client
{ type: "state", channel: "room-1", clientId: "other456", state: { count: 5 }, timestamp: 1700000000000 }
// Send full state (e.g., on initial join for late-joiners)
{ type: "full_state", channel: "room-1", state: { count: 5, theme: "dark" }, timestamp: 1700000000000 }Example WebSocket Server (Node.js)
A minimal relay server that broadcasts state to all clients in a channel:
import { WebSocketServer } from 'ws'
const wss = new WebSocketServer({ port: 8080 })
// channel -> Set<WebSocket>
const channels = new Map()
wss.on('connection', (ws) => {
let clientChannel = null
ws.on('message', (raw) => {
const msg = JSON.parse(raw)
if (msg.type === 'join') {
clientChannel = msg.channel
if (!channels.has(clientChannel)) {
channels.set(clientChannel, new Set())
}
channels.get(clientChannel).add(ws)
return
}
if (msg.type === 'state' && clientChannel) {
// Broadcast to all OTHER clients in the same channel
for (const client of channels.get(clientChannel) || []) {
if (client !== ws && client.readyState === 1) {
client.send(JSON.stringify(msg))
}
}
}
})
ws.on('close', () => {
if (clientChannel && channels.has(clientChannel)) {
channels.get(clientChannel).delete(ws)
if (channels.get(clientChannel).size === 0) {
channels.delete(clientChannel)
}
}
})
})
console.log('Sync server running on ws://localhost:8080')Production tip: For production, consider using a library like
wswithuWebSockets.jsfor better performance, adding authentication (verify tokens in theconnectionevent), and using Redis pub/sub if you need to scale across multiple server instances.
Plugins
Bridges support a plugin system via lifecycle hooks. The persist and sync plugins are the built-in examples, but you can write your own.
Plugin Lifecycle
createBridge() called
│
├── 1. State initialized from initialState
├── 2. Bridge registered in global registry
├── 3. plugin.onInit(bridgeApi) <-- Bridge is fully constructed
│
▼
bridge.setState() called
│
├── 4. State updated (merge or replace)
├── 5. Listeners notified
├── 6. plugin.onStateChange(state, prev) <-- After listeners
│
▼
bridge.destroy() called
│
├── 7. Bridge removed from registry
├── 8. plugin.onDestroy() <-- Cleanup
└── 9. All listeners clearedPlugin Interface
interface BridgePlugin<T extends State> {
/** Unique plugin name (for debugging) */
name: string
/** Called once after the bridge is fully initialized and registered */
onInit?: (bridge: BridgeApi<T>) => void
/** Called after every state change, after listeners are notified */
onStateChange?: (state: T, previousState: T) => void
/** Called when bridge.destroy() is invoked, before listeners are cleared */
onDestroy?: () => void
}Writing a Custom Plugin
Example — Logger Plugin:
import type { BridgePlugin } from 'shared-state-bridge'
function createLoggerPlugin<T extends State>(options?: {
collapsed?: boolean
}): BridgePlugin<T> {
return {
name: 'logger',
onInit: (bridge) => {
console.log(`[logger] Bridge "${bridge.getName()}" initialized with:`, bridge.getState())
},
onStateChange: (state, previousState) => {
const method = options?.collapsed ? console.groupCollapsed : console.group
method('[logger] State change')
console.log('Previous:', previousState)
console.log('Current:', state)
console.groupEnd()
},
onDestroy: () => {
console.log('[logger] Bridge destroyed')
},
}
}
// Usage
const bridge = createBridge({
name: 'app',
initialState: { count: 0 },
plugins: [createLoggerPlugin({ collapsed: true })],
})Example — Validation Plugin:
function createValidationPlugin<T extends State>(
validate: (state: T) => boolean,
errorMessage?: string
): BridgePlugin<T> {
return {
name: 'validation',
onStateChange: (state) => {
if (!validate(state)) {
console.error(errorMessage ?? '[validation] Invalid state:', state)
}
},
}
}
// Usage
const bridge = createBridge({
name: 'counter',
initialState: { count: 0 },
plugins: [
createValidationPlugin(
(s) => s.count >= 0,
'Count cannot be negative!'
),
],
})Combining multiple plugins:
const bridge = createBridge({
name: 'app',
initialState: { theme: 'light', count: 0 },
plugins: [
persist({ adapter: localStorageAdapter, key: 'app', pick: ['theme'] }),
createLoggerPlugin(),
createValidationPlugin((s) => typeof s.count === 'number'),
],
})Plugins execute in array order — onInit and onStateChange are called on plugin 1, then plugin 2, then plugin 3.
TypeScript
Full type inference throughout the API:
interface AppState {
count: number
theme: 'light' | 'dark'
user: { name: string } | null
}
// createBridge infers T from initialState (or use explicit generic)
const bridge = createBridge<AppState>({
name: 'app',
initialState: { count: 0, theme: 'light', user: null },
})
// setState is fully typed
bridge.setState({ count: 1 }) // OK
bridge.setState({ count: 'string' }) // Type error: string is not number
bridge.setState({ unknown: true }) // Type error: unknown key
// Updater function receives correctly typed state
bridge.setState(prev => ({
count: prev.count + 1, // prev is AppState
}))
// Selectors infer return type
const count = useBridgeState(bridge, s => s.count)
// ^? number
const theme = useBridgeState(bridge, s => s.theme)
// ^? 'light' | 'dark'
// getBridge with type parameter
const b = getBridge<AppState>('app')
b.getState().theme // 'light' | 'dark'
b.getState().unknown // Type error
// Subscribe selector is typed
bridge.subscribe(
s => s.user, // selector returns { name: string } | null
(user, prevUser) => { // user and prevUser are { name: string } | null
console.log(user?.name)
}
)Type Exports
All types are exported for use in your own code:
import type {
State, // Record<string, unknown>
BridgeApi, // Bridge instance type
BridgeConfig, // createBridge config
BridgePlugin, // Plugin interface
PersistAdapter, // Storage adapter interface
PersistOptions, // persist() config
SetState, // setState signature
Subscribe, // subscribe signature
Listener, // Full-state listener type
SelectorListener, // Selector listener type
Selector, // Selector function type
EqualityFn, // Equality function type
} from 'shared-state-bridge'Monorepo Setup (Turborepo / Nx)
1. Add as a Shared Dependency
Add shared-state-bridge to a shared/common package in your monorepo:
// packages/shared/package.json
{
"name": "@myorg/shared",
"dependencies": {
"shared-state-bridge": "^1.0.0"
}
}2. Define Bridges in the Shared Package
// packages/shared/src/bridges.ts
import { createBridge } from 'shared-state-bridge'
// --- Auth Bridge ---
export interface AuthState {
user: { id: string; name: string; email: string } | null
token: string | null
isLoading: boolean
}
export const authBridge = createBridge<AuthState>({
name: 'auth',
initialState: { user: null, token: null, isLoading: false },
})
// --- UI Bridge ---
export interface UIState {
sidebarOpen: boolean
modal: string | null
toasts: Array<{ id: string; message: string }>
}
export const uiBridge = createBridge<UIState>({
name: 'ui',
initialState: { sidebarOpen: false, modal: null, toasts: [] },
})3. Use from Any Package
Web (Next.js):
// packages/web/src/components/Header.tsx
import { useBridgeState } from 'shared-state-bridge/react'
import { getBridge } from 'shared-state-bridge'
import type { AuthState } from '@myorg/shared'
const auth = getBridge<AuthState>('auth')
function Header() {
const user = useBridgeState(auth, s => s.user)
return (
<header>
<span>{user?.name ?? 'Guest'}</span>
</header>
)
}Mobile (React Native):
// packages/mobile/src/screens/Profile.tsx
import { useBridgeState } from 'shared-state-bridge/react'
import { getBridge } from 'shared-state-bridge'
import type { AuthState } from '@myorg/shared'
const auth = getBridge<AuthState>('auth')
function ProfileScreen() {
const user = useBridgeState(auth, s => s.user)
return (
<View>
<Text>{user?.name}</Text>
<Text>{user?.email}</Text>
</View>
)
}Service Package (No React):
// packages/analytics/src/tracker.ts
import { getBridge } from 'shared-state-bridge'
import type { AuthState } from '@myorg/shared'
const auth = getBridge<AuthState>('auth')
auth.subscribe(
s => s.user,
(user) => {
if (user) {
analytics.identify(user.id, { name: user.name })
}
}
)Project Structure Example
my-monorepo/
├── packages/
│ ├── shared/ # Bridge definitions + types
│ │ └── src/bridges.ts
│ ├── web/ # Next.js app
│ │ └── src/components/Header.tsx
│ ├── mobile/ # React Native app
│ │ └── src/screens/Profile.tsx
│ └── analytics/ # Service package (no React)
│ └── src/tracker.ts
├── turbo.json / nx.json
└── package.jsonAPI Reference
Core API
createBridge<T>(config): BridgeApi<T>
Creates and registers a new bridge store.
| Parameter | Type | Description |
|---|---|---|
| config.name | string | Unique name for the global registry |
| config.initialState | T | Initial state object |
| config.plugins | BridgePlugin<T>[] | Optional plugins array |
Returns: BridgeApi<T> instance
Throws: If a bridge with the same name already exists
getBridge<T>(name): BridgeApi<T>
Retrieves an existing bridge from the global registry.
| Parameter | Type | Description |
|---|---|---|
| name | string | The bridge name |
Returns: BridgeApi<T> — the exact same instance that was created
Throws: If no bridge with that name exists
hasBridge(name): boolean
Checks if a bridge exists in the registry without throwing.
listBridges(): string[]
Returns an array of all registered bridge names.
BridgeApi Instance Methods
These are the methods available on the object returned by createBridge().
bridge.getState(): T
Returns the current state snapshot. Synchronous.
bridge.setState(partial): void
Updates state by shallow-merging partial into current state.
// Object form
bridge.setState({ count: 1 })
// Updater function form
bridge.setState(prev => ({ count: prev.count + 1 }))bridge.setState(state, true): void
Replaces the entire state (no merge).
bridge.setState({ count: 0, theme: 'light' }, true)bridge.subscribe(listener): () => void
Subscribes to all state changes. Returns an unsubscribe function.
const unsub = bridge.subscribe((state, previousState) => { ... })
unsub() // stop listeningbridge.subscribe(selector, listener, options?): () => void
Subscribes with a selector. Listener only fires when the selected value changes.
const unsub = bridge.subscribe(
s => s.count,
(count, prevCount) => { ... },
{ equalityFn: Object.is, fireImmediately: false }
)bridge.getInitialState(): T
Returns the original initial state (never changes).
bridge.getName(): string
Returns the bridge's registered name.
bridge.destroy(): void
Removes from registry, calls plugin onDestroy, clears listeners. Idempotent.
React API
Import from shared-state-bridge/react.
<BridgeProvider bridge={bridge}>
Context provider. Makes bridge available to useBridge() and useBridgeState(selector).
useBridge<T>(): BridgeApi<T>
Returns bridge from context. Throws outside BridgeProvider.
useBridgeState(selector, options?): U
Subscribes to state from context bridge.
useBridgeState(bridge, selector, options?): U
Subscribes to state from a direct bridge reference.
useBridgeState(bridge): T
Subscribes to the full state.
Options:
| Option | Type | Default | Description |
|---|---|---|---|
| shallow | boolean | false | Use shallow equality to prevent re-renders from object selectors |
Persist API
Import from shared-state-bridge/persist.
persist<T>(options): BridgePlugin<T>
Creates a persistence plugin. See Persistence Options Reference for full options.
localStorageAdapter: PersistAdapter
Pre-built adapter for window.localStorage. Safe in SSR (returns null).
asyncStorageAdapter(storage): PersistAdapter
Factory that wraps a React Native AsyncStorage instance.
import AsyncStorage from '@react-native-async-storage/async-storage'
const adapter = asyncStorageAdapter(AsyncStorage)memoryAdapter(): PersistAdapter
Creates an in-memory storage backend. Each call returns a fresh, isolated store.
Sync API
sync<T>(options: SyncOptions<T>): BridgePlugin<T>
Creates a WebSocket sync plugin. Pass it in the plugins array of createBridge().
import { sync } from 'shared-state-bridge/sync'
sync({
url: 'wss://your-server.com/sync',
channel: 'room-1',
pick: ['theme'],
throttleMs: 50,
onConnect: () => console.log('connected'),
})See Sync Options Reference for all available options.
SyncConnection
Low-level WebSocket connection manager with auto-reconnect, message buffering, and exponential backoff. Used internally by the sync plugin, but exported for advanced use cases.
import { SyncConnection } from 'shared-state-bridge/sync'
const conn = new SyncConnection({
url: 'wss://your-server.com/sync',
reconnect: true,
maxReconnectAttempts: 10,
reconnectInterval: 1000,
maxReconnectInterval: 30000,
onConnect: () => {},
onDisconnect: () => {},
onMessage: (msg) => {},
onError: (err) => {},
})
conn.connect()
conn.send({ type: 'state', channel: 'room-1', clientId: 'abc', state: {}, timestamp: Date.now() })
conn.destroy()Types
All types are exported from the main entry point:
| Type | Description |
|---|---|
| State | Base state constraint: Record<string, unknown> |
| BridgeApi<T> | Bridge instance interface with all methods |
| BridgeConfig<T> | Configuration object for createBridge |
| BridgePlugin<T> | Plugin interface with lifecycle hooks |
| PersistAdapter | Storage adapter interface (3 methods) |
| PersistOptions<T> | Full configuration for the persist plugin |
| SetState<T> | Type signature for bridge.setState |
| Subscribe<T> | Type signature for bridge.subscribe |
| Listener<T> | (state: T, previousState: T) => void |
| SelectorListener<T, U> | (slice: U, previousSlice: U) => void |
| SubscribeOptions<U> | Options for selector-based subscribe |
| Selector<T, U> | (state: T) => U |
| EqualityFn<U> | (a: U, b: U) => boolean |
| SyncOptions<T> | Configuration for the sync plugin |
| JoinMessage | { type: "join", channel, clientId } |
| StateMessage | { type: "state", channel, clientId, state, timestamp } |
| FullStateMessage | { type: "full_state", channel, state, timestamp } |
| OutboundMessage | JoinMessage \| StateMessage |
| InboundMessage | StateMessage \| FullStateMessage |
Architecture Deep Dive
State Update Flow
bridge.setState({ count: 1 })
│
├── 1. Resolve partial: if function, call with current state
│ nextPartial = isFunction(partial) ? partial(state) : partial
│
├── 2. Apply update:
│ if (replace) state = nextPartial
│ else state = Object.assign({}, state, nextPartial) // new reference
│
├── 3. Check: Object.is(state, previousState)?
│ YES → return (no notifications)
│ NO → continue
│
├── 4. Notify listeners (Set, insertion order):
│ listeners.forEach(fn => fn(state, previousState))
│
└── 5. Notify plugins:
plugins.forEach(p => p.onStateChange?.(state, previousState))Key detail: Object.assign({}, state, nextPartial) always creates a new object reference. This means even setState({}) with an empty object will create a new reference and trigger listeners. However, setState(sameRef, true) with the exact same reference will be caught by the Object.is check and skip notifications.
Selector Re-render Optimization
State update: { count: 1, theme: 'dark' } -> { count: 2, theme: 'dark' }
Component A: useBridgeState(bridge, s => s.count)
-> getSnapshot() returns 2 (was 1)
-> Object.is(1, 2) === false
-> RE-RENDERS (correct: count changed)
Component B: useBridgeState(bridge, s => s.theme)
-> getSnapshot() returns 'dark' (was 'dark')
-> Object.is('dark', 'dark') === true
-> SKIPS RE-RENDER (correct: theme unchanged)
Component C: useBridgeState(bridge, s => ({ count: s.count, theme: s.theme }))
-> getSnapshot() returns NEW object { count: 2, theme: 'dark' }
-> Object.is(prevObj, newObj) === false (different references!)
-> RE-RENDERS (unnecessary: theme didn't change)
Component D: useBridgeState(bridge, s => ({ count: s.count, theme: s.theme }), { shallow: true })
-> getSnapshot() returns { count: 2, theme: 'dark' }
-> shallowEqual check: count changed (1 !== 2)
-> Returns NEW reference
-> RE-RENDERS (correct: count changed)
Component E (same selector, theme unchanged):
-> getSnapshot() returns { count: 2, theme: 'dark' }
-> shallowEqual check: all values same
-> Returns PREVIOUS reference (same object)
-> Object.is(prevRef, prevRef) === true
-> SKIPS RE-RENDER (correct!)Global Registry Internals
// The registry is a Map stored on globalThis with a Symbol.for key:
const REGISTRY_KEY = Symbol.for('shared-state-bridge.registry')
// globalThis[REGISTRY_KEY] = Map {
// 'auth' => BridgeApi { ... },
// 'app' => BridgeApi { ... },
// 'ui' => BridgeApi { ... },
// }
// Why Symbol.for() is critical:
//
// Module A: Symbol.for('shared-state-bridge.registry') → Symbol(123)
// Module B: Symbol.for('shared-state-bridge.registry') → Symbol(123) (SAME!)
// Duplicate package: Symbol.for('shared-state-bridge.registry') → Symbol(123) (SAME!)
//
// Plain string would also work, but Symbols avoid collisions with other
// libraries that might use globalThis.Persistence Flow
createBridge({ plugins: [persist({ adapter, key, version: 2 })] })
│
├── Bridge initialized with initialState
├── Bridge registered in global registry
│
└── persist.onInit(bridge):
│
├── Set up throttled writer function
│
└── Hydrate (async):
│
├── raw = await adapter.getItem(key)
│
├── if null → skip (first run, nothing persisted)
│
├── envelope = deserialize(raw) // { state, version }
│
├── if envelope.version === current version:
│ └── bridge.setState(envelope.state) // merge persisted state
│
├── if version mismatch + migrate function:
│ ├── migrated = migrate(envelope.state, envelope.version)
│ └── bridge.setState(migrated)
│
└── if version mismatch + NO migrate:
└── adapter.removeItem(key) // discard stale data
On every bridge.setState():
│
└── persist.onStateChange(state):
│
├── filtered = filterState(state) // apply pick/omit
├── envelope = { state: filtered, version }
├── serialized = serialize(envelope)
└── adapter.setItem(key, serialized) // throttledSync Flow
bridge.setState({ count: 5 }) // local update
│
├── plugins.forEach(p => p.onStateChange(state, prev))
│ │
│ └── sync.onStateChange(state)
│ ├── isApplyingRemote? return (echo guard)
│ └── sendState(state) // throttled
│ ├── filtered = filterState(state) // apply pick/omit
│ └── connection.send({ type: "state", channel, clientId, state: filtered })
│ ├── ws.readyState === OPEN? ws.send(data)
│ └── else: buffer.push(data) // flush on reconnect
│
▼ (incoming from server)
ws.onmessage(data)
│
├── parse JSON
├── message.clientId === ownClientId? skip (echo prevention)
├── resolve? stateToApply = resolve(local, remote)
│ else: stateToApply = remoteState (last-write-wins)
├── isApplyingRemote = true // prevent re-broadcast
├── bridge.setState(stateToApply)
└── isApplyingRemote = falseGotchas and Common Pitfalls
1. Bridge Name Collisions
createBridge({ name: 'app', initialState: {} })
createBridge({ name: 'app', initialState: {} })
// Error: A bridge named "app" already exists.Fix: Use unique names, or check with hasBridge('app') first, or destroy() the old bridge.
2. getBridge Before createBridge
const bridge = getBridge('auth')
// Error: No bridge named "auth" found.Fix: Ensure the package that calls createBridge is imported/executed before any getBridge calls. In a monorepo, the shared package with bridge definitions should be imported at app startup.
3. Object Selectors Without Shallow
// This causes re-renders on EVERY state change
const data = useBridgeState(bridge, s => ({
a: s.a,
b: s.b,
}))Fix: Add { shallow: true } or use separate primitive selectors.
4. Stale Closures in Updaters
// WRONG: count is captured once
const count = bridge.getState().count
bridge.setState({ count: count + 1 })
bridge.setState({ count: count + 1 }) // Both set count to the same value!
// CORRECT: use updater function for sequential updates
bridge.setState(s => ({ count: s.count + 1 }))
bridge.setState(s => ({ count: s.count + 1 })) // Correctly increments twice5. Async Hydration Timing
Hydration from async adapters (AsyncStorage) happens asynchronously. State may briefly hold initialState before the persisted state loads:
const bridge = createBridge({
name: 'app',
initialState: { theme: 'light' },
plugins: [persist({ adapter: asyncStorageAdapter(AsyncStorage), key: 'app' })],
})
bridge.getState() // { theme: 'light' } (initial, not yet hydrated)
// After microtask:
// bridge.getState() // { theme: 'dark' } (hydrated from storage)Fix: Design your UI to handle the initial state gracefully, or use a loading flag.
6. Mutating State Directly
// WRONG: mutation won't trigger listeners
const state = bridge.getState()
state.count = 5
// CORRECT: use setState
bridge.setState({ count: 5 })7. Subscribing in useEffect Without Cleanup
// WRONG: leaks a listener on every render
useEffect(() => {
bridge.subscribe(listener)
})
// CORRECT: return the unsubscribe function
useEffect(() => {
return bridge.subscribe(listener)
}, [])
// BEST: use useBridgeState hook instead
const value = useBridgeState(bridge, selector)FAQ
How does cross-package sharing work?
Bridges are stored in a Map on globalThis using Symbol.for() as the key. Since Symbol.for() returns the same symbol across all modules (even duplicated packages), all packages in your monorepo access the same registry. See Architecture Deep Dive for details.
Can I use this without React?
Yes. The core (shared-state-bridge) has zero dependencies and works in any JS environment — Node.js, browsers, React Native, Deno, Bun. The React hooks are a separate, optional entry point (shared-state-bridge/react).
Does it work with SSR / Next.js?
Yes. useBridgeState uses useSyncExternalStore with a getServerSnapshot that returns getInitialState(), which is SSR-safe. See Server-Side Rendering.
How does this compare to Zustand?
Zustand is a general-purpose state manager. shared-state-bridge is specifically designed for cross-package state sharing in monorepos. The core API is similar (event-emitter store + useSyncExternalStore), but the global registry and named bridges are unique to this library. If you only need state within a single package, Zustand is great. If you need to share state across packages in a monorepo, shared-state-bridge is purpose-built for that.
What happens if two packages create a bridge with the same name?
An error is thrown: A bridge named "x" already exists. This prevents silent overwrites. Use getBridge() to access existing bridges, or call destroy() first to remove the old one.
Does it support React Native?
Yes. The core and React hooks work identically in React Native. For persistence, use asyncStorageAdapter() with @react-native-async-storage/async-storage.
What's the bundle size?
- Core: ~1.2 KB gzipped
- React hooks: ~1.1 KB gzipped
- Persist plugin: ~1.0 KB gzipped
- Sync plugin: ~1.8 KB gzipped
- All four combined: ~5.1 KB gzipped
Each entry point is independently tree-shakeable. If you only use the core, React, persist, and sync code is never included.
Can I have multiple BridgeProviders?
Yes. Each BridgeProvider provides its own bridge to its subtree. Components use the nearest provider. You can nest providers for different bridges.
Is it concurrent-safe (React 18)?
Yes. useBridgeState uses useSyncExternalStore, which is React's official API for integrating external stores with concurrent features like useTransition and Suspense.
Can I use this in a non-monorepo project?
Absolutely. The core API works anywhere. The cross-package registry feature just happens to shine in monorepos, but a single-package app can use createBridge + React hooks perfectly fine as a lightweight state manager.
Does the sync plugin actually sync between different apps (Next.js + React Native)?
Yes! The sync plugin connects to a WebSocket server and broadcasts state changes to all clients in the same channel. Unlike the core bridge (which shares within the same JS runtime), the sync plugin enables true cross-app real-time state synchronization. You need to provide your own WebSocket server — a minimal example is included in the docs.
Can I use both persist and sync together? Yes. They are independent plugins that compose naturally:
createBridge({
name: 'app',
initialState: { theme: 'light', count: 0 },
plugins: [
persist({ adapter: localStorageAdapter, key: 'app' }),
sync({ url: 'wss://server.com/sync', channel: 'room-1' }),
],
})State persists locally AND syncs in real-time with other connected apps.
Support
If you find this package useful, consider buying me a coffee!
License
MIT
