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

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.

Readme

Share React State Across Packages in Turborepo & Nx Monorepos | shared-state-bridge

npm version npm downloads bundle size license TypeScript

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 -- useSyncExternalStore with 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

# npm
npm install shared-state-bridge

# yarn
yarn add shared-state-bridge

# pnpm
pnpm add shared-state-bridge

Note: 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 name must be unique across your entire application. Attempting to create two bridges with the same name throws an error.
  • initialState must 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 preserved

setState(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 setState that produces a new state reference.
  • If setState is called but the state reference doesn't change (e.g., replacing with the same object via setState(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:

  1. Removes the bridge from the global registry (it can no longer be found via getBridge)
  2. Calls onDestroy on all plugins
  3. Clears all listeners (no more notifications)
  4. Marks the bridge as destroyed — subsequent setState/subscribe calls 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: { ... } }) // OK

Cross-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-bridge in node_modules (a common pitfall), both versions use the same Symbol.for() key and access the same Map
  • 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: BridgeProvider is 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 }
): U

Requires 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 }
): U

No 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>
): T

Returns 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 changes

Object 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:

  1. The hook computes the new selector result
  2. It compares each key/value with the previous result using Object.is
  3. If all keys and values match, it returns the previous object reference
  4. React's useSyncExternalStore sees 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 migrate is provided: call migrate(persisted, oldVersion) and use the result
  • If versions differ AND migrate is 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-123

How 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 ws with uWebSockets.js for better performance, adding authentication (verify tokens in the connection event), 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 cleared

Plugin 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.json

API 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 listening

bridge.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)  // throttled

Sync 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 = false

Gotchas 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 twice

5. 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!

Buy Me A Coffee


License

MIT