shared-state-bridge 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1968 -0
- package/dist/index.cjs +125 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +114 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/dist/persist.cjs +147 -0
- package/dist/persist.cjs.map +1 -0
- package/dist/persist.d.cts +146 -0
- package/dist/persist.d.ts +146 -0
- package/dist/persist.js +142 -0
- package/dist/persist.js.map +1 -0
- package/dist/react.cjs +126 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +100 -0
- package/dist/react.d.ts +100 -0
- package/dist/react.js +122 -0
- package/dist/react.js.map +1 -0
- package/dist/sync.cjs +249 -0
- package/dist/sync.cjs.map +1 -0
- package/dist/sync.d.cts +154 -0
- package/dist/sync.d.ts +154 -0
- package/dist/sync.js +246 -0
- package/dist/sync.js.map +1 -0
- package/package.json +103 -0
package/README.md
ADDED
|
@@ -0,0 +1,1968 @@
|
|
|
1
|
+
# shared-state-bridge
|
|
2
|
+
|
|
3
|
+
Lightweight shared state bridge for monorepo apps. Sync state across packages in Turborepo / Nx with TypeScript-first API, React hooks, optional persistence, and real-time WebSocket sync across apps.
|
|
4
|
+
|
|
5
|
+
- **Zero-config cross-package sharing** — named bridges resolve via global registry
|
|
6
|
+
- **React 18+ hooks** — `useSyncExternalStore` with selector-based re-render optimization
|
|
7
|
+
- **Optional persistence** — localStorage, AsyncStorage, or custom adapters
|
|
8
|
+
- **Real-time sync** — WebSocket-based state sync across different apps (Next.js + React Native)
|
|
9
|
+
- **Tiny** — core ~1.2KB gzipped, no dependencies
|
|
10
|
+
- **TypeScript-first** — full type inference and safety
|
|
11
|
+
- **Plugin system** — extend with persistence, sync, logging, or your own plugins
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Table of Contents
|
|
16
|
+
|
|
17
|
+
- [Install](#install)
|
|
18
|
+
- [Quick Start](#quick-start)
|
|
19
|
+
- [Core Concepts](#core-concepts)
|
|
20
|
+
- [Creating a Bridge](#creating-a-bridge)
|
|
21
|
+
- [Reading and Updating State](#reading-and-updating-state)
|
|
22
|
+
- [Subscribing to Changes](#subscribing-to-changes)
|
|
23
|
+
- [Destroying a Bridge](#destroying-a-bridge)
|
|
24
|
+
- [Cross-Package Sharing](#cross-package-sharing)
|
|
25
|
+
- [How It Works (Architecture)](#how-it-works-architecture)
|
|
26
|
+
- [React Integration](#react-integration)
|
|
27
|
+
- [BridgeProvider](#bridgeprovider)
|
|
28
|
+
- [useBridgeState](#usebridgestate)
|
|
29
|
+
- [useBridge](#usebridge)
|
|
30
|
+
- [Preventing Unnecessary Re-renders](#preventing-unnecessary-re-renders)
|
|
31
|
+
- [Server-Side Rendering (Next.js)](#server-side-rendering-nextjs)
|
|
32
|
+
- [Persistence](#persistence)
|
|
33
|
+
- [Web — localStorage](#web--localstorage)
|
|
34
|
+
- [React Native — AsyncStorage](#react-native--asyncstorage)
|
|
35
|
+
- [Custom Adapter](#custom-adapter)
|
|
36
|
+
- [Persistence Options Reference](#persistence-options-reference)
|
|
37
|
+
- [Schema Migrations](#schema-migrations)
|
|
38
|
+
- [Testing with memoryAdapter](#testing-with-memoryadapter)
|
|
39
|
+
- [Real-Time Sync (WebSocket)](#real-time-sync-websocket)
|
|
40
|
+
- [Basic Usage](#basic-usage)
|
|
41
|
+
- [How It Works](#how-it-works)
|
|
42
|
+
- [Selective Sync (pick / omit)](#selective-sync-pick--omit)
|
|
43
|
+
- [Custom Conflict Resolution](#custom-conflict-resolution)
|
|
44
|
+
- [Reconnection](#reconnection)
|
|
45
|
+
- [Callbacks](#callbacks)
|
|
46
|
+
- [Sync Options Reference](#sync-options-reference)
|
|
47
|
+
- [Wire Protocol](#wire-protocol)
|
|
48
|
+
- [Example WebSocket Server (Node.js)](#example-websocket-server-nodejs)
|
|
49
|
+
- [Plugins](#plugins)
|
|
50
|
+
- [Plugin Lifecycle](#plugin-lifecycle)
|
|
51
|
+
- [Writing a Custom Plugin](#writing-a-custom-plugin)
|
|
52
|
+
- [TypeScript](#typescript)
|
|
53
|
+
- [Monorepo Setup (Turborepo / Nx)](#monorepo-setup-turborepo--nx)
|
|
54
|
+
- [API Reference](#api-reference)
|
|
55
|
+
- [Core API](#core-api)
|
|
56
|
+
- [BridgeApi Instance Methods](#bridgeapi-instance-methods)
|
|
57
|
+
- [React API](#react-api)
|
|
58
|
+
- [Persist API](#persist-api)
|
|
59
|
+
- [Sync API](#sync-api)
|
|
60
|
+
- [Types](#types)
|
|
61
|
+
- [Architecture Deep Dive](#architecture-deep-dive)
|
|
62
|
+
- [State Update Flow](#state-update-flow)
|
|
63
|
+
- [Selector Re-render Optimization](#selector-re-render-optimization)
|
|
64
|
+
- [Global Registry Internals](#global-registry-internals)
|
|
65
|
+
- [Persistence Flow](#persistence-flow)
|
|
66
|
+
- [Sync Flow](#sync-flow)
|
|
67
|
+
- [Gotchas and Common Pitfalls](#gotchas-and-common-pitfalls)
|
|
68
|
+
- [FAQ](#faq)
|
|
69
|
+
- [Contributing](#contributing)
|
|
70
|
+
- [Support](#support)
|
|
71
|
+
- [License](#license)
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# npm
|
|
79
|
+
npm install shared-state-bridge
|
|
80
|
+
|
|
81
|
+
# yarn
|
|
82
|
+
yarn add shared-state-bridge
|
|
83
|
+
|
|
84
|
+
# pnpm
|
|
85
|
+
pnpm add shared-state-bridge
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
> **Note:** React is an optional peer dependency. If you only use the core API (no hooks), React is not required.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Quick Start
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { createBridge } from 'shared-state-bridge'
|
|
96
|
+
|
|
97
|
+
const bridge = createBridge({
|
|
98
|
+
name: 'app',
|
|
99
|
+
initialState: { count: 0, theme: 'light' },
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Read state
|
|
103
|
+
bridge.getState() // { count: 0, theme: 'light' }
|
|
104
|
+
|
|
105
|
+
// Update state (partial merge)
|
|
106
|
+
bridge.setState({ count: 1 })
|
|
107
|
+
|
|
108
|
+
// Update with updater function
|
|
109
|
+
bridge.setState(prev => ({ count: prev.count + 1 }))
|
|
110
|
+
|
|
111
|
+
// Subscribe to changes
|
|
112
|
+
const unsub = bridge.subscribe((state, prev) => {
|
|
113
|
+
console.log('Changed:', state)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// Unsubscribe when done
|
|
117
|
+
unsub()
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### With React
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
import { BridgeProvider, useBridgeState, useBridge } from 'shared-state-bridge/react'
|
|
124
|
+
|
|
125
|
+
function App() {
|
|
126
|
+
return (
|
|
127
|
+
<BridgeProvider bridge={bridge}>
|
|
128
|
+
<Counter />
|
|
129
|
+
</BridgeProvider>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function Counter() {
|
|
134
|
+
const count = useBridgeState<AppState, number>(s => s.count)
|
|
135
|
+
const bridge = useBridge<AppState>()
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<button onClick={() => bridge.setState(s => ({ count: s.count + 1 }))}>
|
|
139
|
+
Count: {count}
|
|
140
|
+
</button>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### With Persistence
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { createBridge } from 'shared-state-bridge'
|
|
149
|
+
import { persist, localStorageAdapter } from 'shared-state-bridge/persist'
|
|
150
|
+
|
|
151
|
+
const bridge = createBridge({
|
|
152
|
+
name: 'app',
|
|
153
|
+
initialState: { theme: 'light', count: 0 },
|
|
154
|
+
plugins: [
|
|
155
|
+
persist({
|
|
156
|
+
adapter: localStorageAdapter,
|
|
157
|
+
key: 'app-state',
|
|
158
|
+
pick: ['theme'], // only persist theme
|
|
159
|
+
}),
|
|
160
|
+
],
|
|
161
|
+
})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Core Concepts
|
|
167
|
+
|
|
168
|
+
### Creating a Bridge
|
|
169
|
+
|
|
170
|
+
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.
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
import { createBridge } from 'shared-state-bridge'
|
|
174
|
+
|
|
175
|
+
interface AppState {
|
|
176
|
+
user: { name: string; email: string } | null
|
|
177
|
+
theme: 'light' | 'dark'
|
|
178
|
+
notifications: number
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const appBridge = createBridge<AppState>({
|
|
182
|
+
name: 'app', // unique identifier
|
|
183
|
+
initialState: { // required initial state
|
|
184
|
+
user: null,
|
|
185
|
+
theme: 'light',
|
|
186
|
+
notifications: 0,
|
|
187
|
+
},
|
|
188
|
+
plugins: [], // optional plugins array
|
|
189
|
+
})
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Key rules:**
|
|
193
|
+
- The `name` must be unique across your entire application. Attempting to create two bridges with the same name throws an error.
|
|
194
|
+
- `initialState` must be a plain object (not an array, not a primitive).
|
|
195
|
+
- The bridge is immediately registered in the global registry upon creation.
|
|
196
|
+
|
|
197
|
+
### Reading and Updating State
|
|
198
|
+
|
|
199
|
+
#### `getState()`
|
|
200
|
+
|
|
201
|
+
Returns the current state snapshot. This is a synchronous read.
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
const state = appBridge.getState()
|
|
205
|
+
console.log(state.theme) // 'light'
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### `setState(partial)` — Partial Merge
|
|
209
|
+
|
|
210
|
+
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.
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
appBridge.setState({ theme: 'dark' })
|
|
214
|
+
// State is now: { user: null, theme: 'dark', notifications: 0 }
|
|
215
|
+
// ^^^^^ changed
|
|
216
|
+
// rest preserved
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
#### `setState(updater)` — Updater Function
|
|
220
|
+
|
|
221
|
+
Pass a function to compute the next state based on the current state. Useful when the new value depends on the old one.
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
appBridge.setState(prev => ({
|
|
225
|
+
notifications: prev.notifications + 1,
|
|
226
|
+
}))
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
#### `setState(state, true)` — Full Replacement
|
|
230
|
+
|
|
231
|
+
Pass `true` as the second argument to **replace** the entire state instead of merging.
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
appBridge.setState(
|
|
235
|
+
{ user: null, theme: 'light', notifications: 0 },
|
|
236
|
+
true // replaces entirely — no merge
|
|
237
|
+
)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
> **Important:** After replacement, any keys not included in the new state are gone. Use this carefully.
|
|
241
|
+
|
|
242
|
+
#### `getInitialState()`
|
|
243
|
+
|
|
244
|
+
Returns the original initial state that was passed to `createBridge`. This never changes, even after `setState` calls.
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
appBridge.setState({ notifications: 99 })
|
|
248
|
+
appBridge.getInitialState() // { user: null, theme: 'light', notifications: 0 }
|
|
249
|
+
appBridge.getState() // { user: null, theme: 'light', notifications: 99 }
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Subscribing to Changes
|
|
253
|
+
|
|
254
|
+
#### Full State Subscription
|
|
255
|
+
|
|
256
|
+
Listen to every state change:
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
const unsub = appBridge.subscribe((state, previousState) => {
|
|
260
|
+
console.log('State changed:', state)
|
|
261
|
+
console.log('Previous:', previousState)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// Later: stop listening
|
|
265
|
+
unsub()
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Behavior:**
|
|
269
|
+
- The listener is called **synchronously** after each `setState` that produces a new state reference.
|
|
270
|
+
- 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.
|
|
271
|
+
- Multiple listeners fire in the order they were subscribed.
|
|
272
|
+
|
|
273
|
+
#### Selector-Based Subscription
|
|
274
|
+
|
|
275
|
+
Listen only when a specific slice of state changes:
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
const unsub = appBridge.subscribe(
|
|
279
|
+
state => state.theme, // selector
|
|
280
|
+
(theme, previousTheme) => { // only called when theme changes
|
|
281
|
+
console.log(`Theme: ${previousTheme} -> ${theme}`)
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
appBridge.setState({ notifications: 5 }) // listener NOT called (theme unchanged)
|
|
286
|
+
appBridge.setState({ theme: 'dark' }) // listener called: 'light' -> 'dark'
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
#### Subscription Options
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
appBridge.subscribe(
|
|
293
|
+
state => state.notifications,
|
|
294
|
+
(count, prevCount) => updateBadge(count),
|
|
295
|
+
{
|
|
296
|
+
// Fire the listener immediately with the current value
|
|
297
|
+
fireImmediately: true,
|
|
298
|
+
|
|
299
|
+
// Custom equality function (default: Object.is)
|
|
300
|
+
equalityFn: (a, b) => Math.abs(a - b) < 5,
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
| Option | Type | Default | Description |
|
|
306
|
+
|---|---|---|---|
|
|
307
|
+
| `fireImmediately` | `boolean` | `false` | Call the listener once immediately with the current value |
|
|
308
|
+
| `equalityFn` | `(a, b) => boolean` | `Object.is` | Custom comparison to determine if the slice changed |
|
|
309
|
+
|
|
310
|
+
### Destroying a Bridge
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
appBridge.destroy()
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**What `destroy()` does:**
|
|
317
|
+
1. Removes the bridge from the global registry (it can no longer be found via `getBridge`)
|
|
318
|
+
2. Calls `onDestroy` on all plugins
|
|
319
|
+
3. Clears all listeners (no more notifications)
|
|
320
|
+
4. Marks the bridge as destroyed — subsequent `setState`/`subscribe` calls are silent no-ops
|
|
321
|
+
|
|
322
|
+
**When to use it:**
|
|
323
|
+
- In tests, to clean up between test cases
|
|
324
|
+
- When unmounting a micro-frontend
|
|
325
|
+
- Before re-creating a bridge with the same name
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
// Re-creating a bridge after destroy
|
|
329
|
+
appBridge.destroy()
|
|
330
|
+
const newBridge = createBridge({ name: 'app', initialState: { ... } }) // OK
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Cross-Package Sharing
|
|
336
|
+
|
|
337
|
+
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.
|
|
338
|
+
|
|
339
|
+
### Package A — Creates the Bridge
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
// packages/shared/src/bridges.ts
|
|
343
|
+
import { createBridge } from 'shared-state-bridge'
|
|
344
|
+
|
|
345
|
+
export interface AuthState {
|
|
346
|
+
user: { id: string; name: string } | null
|
|
347
|
+
token: string | null
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export const authBridge = createBridge<AuthState>({
|
|
351
|
+
name: 'auth',
|
|
352
|
+
initialState: { user: null, token: null },
|
|
353
|
+
})
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### Package B — Accesses the Bridge by Name
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
// packages/dashboard/src/auth.ts
|
|
360
|
+
import { getBridge } from 'shared-state-bridge'
|
|
361
|
+
import type { AuthState } from '@myorg/shared'
|
|
362
|
+
|
|
363
|
+
// Returns the EXACT same instance created in Package A
|
|
364
|
+
const authBridge = getBridge<AuthState>('auth')
|
|
365
|
+
|
|
366
|
+
authBridge.subscribe(
|
|
367
|
+
state => state.user,
|
|
368
|
+
(user) => {
|
|
369
|
+
if (!user) redirectToLogin()
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Registry Utilities
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
import { getBridge, hasBridge, listBridges } from 'shared-state-bridge'
|
|
378
|
+
|
|
379
|
+
// Check existence without throwing
|
|
380
|
+
if (hasBridge('auth')) {
|
|
381
|
+
const auth = getBridge('auth')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// List all registered bridges (useful for debugging)
|
|
385
|
+
console.log(listBridges()) // ['auth', 'app', 'ui']
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### How It Works (Architecture)
|
|
389
|
+
|
|
390
|
+
```
|
|
391
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
392
|
+
│ JavaScript Runtime │
|
|
393
|
+
│ │
|
|
394
|
+
│ globalThis[Symbol.for('shared-state-bridge.registry')] │
|
|
395
|
+
│ ┌───────────────────────────────────────────────────┐ │
|
|
396
|
+
│ │ Map<string, BridgeApi> │ │
|
|
397
|
+
│ │ │ │
|
|
398
|
+
│ │ 'auth' -> BridgeApi { state, listeners, ... } │ │
|
|
399
|
+
│ │ 'app' -> BridgeApi { state, listeners, ... } │ │
|
|
400
|
+
│ │ 'ui' -> BridgeApi { state, listeners, ... } │ │
|
|
401
|
+
│ └───────────────────────────────────────────────────┘ │
|
|
402
|
+
│ ^ ^ │
|
|
403
|
+
│ | | │
|
|
404
|
+
│ ┌──────────────┴──┐ ┌──────────┴───────────┐ │
|
|
405
|
+
│ │ Package A │ │ Package B │ │
|
|
406
|
+
│ │ │ │ │ │
|
|
407
|
+
│ │ createBridge() │ │ getBridge('auth') │ │
|
|
408
|
+
│ │ registers here │ │ reads from here │ │
|
|
409
|
+
│ └─────────────────┘ └──────────────────────┘ │
|
|
410
|
+
└─────────────────────────────────────────────────────────────┘
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
The registry uses `Symbol.for('shared-state-bridge.registry')` as the key on `globalThis`. Why `Symbol.for()` instead of a plain string?
|
|
414
|
+
|
|
415
|
+
- `Symbol.for('x')` returns the **same symbol** across all modules, all files, and even across multiple copies of the package
|
|
416
|
+
- 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`
|
|
417
|
+
- A plain string key like `globalThis.__shared_state_bridge__` could collide with other libraries; symbols cannot
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
## React Integration
|
|
422
|
+
|
|
423
|
+
The React bindings are imported separately from `shared-state-bridge/react` and have React 18+ as an optional peer dependency.
|
|
424
|
+
|
|
425
|
+
### BridgeProvider
|
|
426
|
+
|
|
427
|
+
Provides a bridge instance to the React component tree via context.
|
|
428
|
+
|
|
429
|
+
```tsx
|
|
430
|
+
import { createBridge } from 'shared-state-bridge'
|
|
431
|
+
import { BridgeProvider } from 'shared-state-bridge/react'
|
|
432
|
+
|
|
433
|
+
const bridge = createBridge({
|
|
434
|
+
name: 'app',
|
|
435
|
+
initialState: { count: 0, theme: 'light' },
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
function App() {
|
|
439
|
+
return (
|
|
440
|
+
<BridgeProvider bridge={bridge}>
|
|
441
|
+
<YourApp />
|
|
442
|
+
</BridgeProvider>
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
**Props:**
|
|
448
|
+
|
|
449
|
+
| Prop | Type | Description |
|
|
450
|
+
|---|---|---|
|
|
451
|
+
| `bridge` | `BridgeApi<T>` | The bridge instance to provide |
|
|
452
|
+
| `children` | `React.ReactNode` | Child components |
|
|
453
|
+
|
|
454
|
+
> **Note:** `BridgeProvider` is optional. You can pass bridges directly to hooks if you prefer.
|
|
455
|
+
|
|
456
|
+
### useBridgeState
|
|
457
|
+
|
|
458
|
+
The primary hook for reading bridge state in React components. Uses `useSyncExternalStore` internally for tear-free, concurrent-safe reads.
|
|
459
|
+
|
|
460
|
+
#### Signature 1: From Context
|
|
461
|
+
|
|
462
|
+
```tsx
|
|
463
|
+
function useBridgeState<T extends State, U>(
|
|
464
|
+
selector: (state: T) => U,
|
|
465
|
+
options?: { shallow?: boolean }
|
|
466
|
+
): U
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Requires a `BridgeProvider` ancestor:
|
|
470
|
+
|
|
471
|
+
```tsx
|
|
472
|
+
function ThemeToggle() {
|
|
473
|
+
const theme = useBridgeState<AppState, string>(s => s.theme)
|
|
474
|
+
// Only re-renders when theme changes
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
#### Signature 2: Direct Bridge Reference
|
|
479
|
+
|
|
480
|
+
```tsx
|
|
481
|
+
function useBridgeState<T extends State, U>(
|
|
482
|
+
bridge: BridgeApi<T>,
|
|
483
|
+
selector: (state: T) => U,
|
|
484
|
+
options?: { shallow?: boolean }
|
|
485
|
+
): U
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
No provider needed:
|
|
489
|
+
|
|
490
|
+
```tsx
|
|
491
|
+
function Counter() {
|
|
492
|
+
const count = useBridgeState(bridge, s => s.count)
|
|
493
|
+
// Only re-renders when count changes
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
#### Signature 3: Full State (No Selector)
|
|
498
|
+
|
|
499
|
+
```tsx
|
|
500
|
+
function useBridgeState<T extends State>(
|
|
501
|
+
bridge: BridgeApi<T>
|
|
502
|
+
): T
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Returns the entire state object:
|
|
506
|
+
|
|
507
|
+
```tsx
|
|
508
|
+
function Debug() {
|
|
509
|
+
const state = useBridgeState(bridge)
|
|
510
|
+
// Re-renders on EVERY state change
|
|
511
|
+
return <pre>{JSON.stringify(state, null, 2)}</pre>
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### useBridge
|
|
516
|
+
|
|
517
|
+
Returns the bridge instance from the nearest `BridgeProvider`. Use this for imperative operations like `setState`.
|
|
518
|
+
|
|
519
|
+
```tsx
|
|
520
|
+
import { useBridge } from 'shared-state-bridge/react'
|
|
521
|
+
|
|
522
|
+
function LogoutButton() {
|
|
523
|
+
const bridge = useBridge<AuthState>()
|
|
524
|
+
|
|
525
|
+
return (
|
|
526
|
+
<button onClick={() => bridge.setState({ user: null, token: null })}>
|
|
527
|
+
Log Out
|
|
528
|
+
</button>
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Throws if used outside a `BridgeProvider`.
|
|
534
|
+
|
|
535
|
+
### Preventing Unnecessary Re-renders
|
|
536
|
+
|
|
537
|
+
#### Primitive Selectors (No Problem)
|
|
538
|
+
|
|
539
|
+
Selectors that return primitives (strings, numbers, booleans) work perfectly with the default `Object.is` comparison:
|
|
540
|
+
|
|
541
|
+
```tsx
|
|
542
|
+
const count = useBridgeState(bridge, s => s.count) // number
|
|
543
|
+
const theme = useBridgeState(bridge, s => s.theme) // string
|
|
544
|
+
// These only re-render when the VALUE actually changes
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
#### Object Selectors (Use `{ shallow: true }`)
|
|
548
|
+
|
|
549
|
+
Selectors that return **new objects** create a new reference on every call, causing unnecessary re-renders:
|
|
550
|
+
|
|
551
|
+
```tsx
|
|
552
|
+
// BAD: Creates a new object every render -> re-renders on EVERY state change
|
|
553
|
+
const user = useBridgeState(bridge, s => ({
|
|
554
|
+
name: s.user?.name,
|
|
555
|
+
email: s.user?.email,
|
|
556
|
+
}))
|
|
557
|
+
|
|
558
|
+
// GOOD: Shallow comparison prevents re-render when values haven't changed
|
|
559
|
+
const user = useBridgeState(
|
|
560
|
+
bridge,
|
|
561
|
+
s => ({ name: s.user?.name, email: s.user?.email }),
|
|
562
|
+
{ shallow: true }
|
|
563
|
+
)
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
**How `{ shallow: true }` works internally:**
|
|
567
|
+
|
|
568
|
+
1. The hook computes the new selector result
|
|
569
|
+
2. It compares each key/value with the previous result using `Object.is`
|
|
570
|
+
3. If all keys and values match, it returns the **previous object reference**
|
|
571
|
+
4. React's `useSyncExternalStore` sees the same reference and skips re-rendering
|
|
572
|
+
|
|
573
|
+
#### Alternative: Select Primitives Individually
|
|
574
|
+
|
|
575
|
+
If you only need a few values, separate selectors can be simpler:
|
|
576
|
+
|
|
577
|
+
```tsx
|
|
578
|
+
// Each hook only re-renders when its specific value changes
|
|
579
|
+
const name = useBridgeState(bridge, s => s.user?.name)
|
|
580
|
+
const email = useBridgeState(bridge, s => s.user?.email)
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### Server-Side Rendering (Next.js)
|
|
584
|
+
|
|
585
|
+
`useBridgeState` is SSR-safe. During server rendering, it uses `getInitialState()` as the server snapshot, preventing hydration mismatches.
|
|
586
|
+
|
|
587
|
+
```tsx
|
|
588
|
+
// This works in Next.js App Router and Pages Router
|
|
589
|
+
function ThemeSwitcher() {
|
|
590
|
+
const theme = useBridgeState(bridge, s => s.theme)
|
|
591
|
+
// Server: returns initial state ('light')
|
|
592
|
+
// Client: returns current state (may differ after hydration)
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**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.
|
|
597
|
+
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
## Persistence
|
|
601
|
+
|
|
602
|
+
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.
|
|
603
|
+
|
|
604
|
+
### Web — localStorage
|
|
605
|
+
|
|
606
|
+
```typescript
|
|
607
|
+
import { createBridge } from 'shared-state-bridge'
|
|
608
|
+
import { persist, localStorageAdapter } from 'shared-state-bridge/persist'
|
|
609
|
+
|
|
610
|
+
const bridge = createBridge({
|
|
611
|
+
name: 'app',
|
|
612
|
+
initialState: { theme: 'light', count: 0 },
|
|
613
|
+
plugins: [
|
|
614
|
+
persist({
|
|
615
|
+
adapter: localStorageAdapter,
|
|
616
|
+
key: 'app-state',
|
|
617
|
+
pick: ['theme'], // only persist theme, not count
|
|
618
|
+
}),
|
|
619
|
+
],
|
|
620
|
+
})
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
The `localStorageAdapter` is safe to use in SSR environments — it silently returns `null` when `localStorage` is unavailable.
|
|
624
|
+
|
|
625
|
+
### React Native — AsyncStorage
|
|
626
|
+
|
|
627
|
+
The `asyncStorageAdapter` is a **factory function** that accepts an AsyncStorage instance. This avoids a hard dependency on `@react-native-async-storage/async-storage`.
|
|
628
|
+
|
|
629
|
+
```typescript
|
|
630
|
+
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
631
|
+
import { createBridge } from 'shared-state-bridge'
|
|
632
|
+
import { persist, asyncStorageAdapter } from 'shared-state-bridge/persist'
|
|
633
|
+
|
|
634
|
+
const bridge = createBridge({
|
|
635
|
+
name: 'app',
|
|
636
|
+
initialState: { theme: 'light' },
|
|
637
|
+
plugins: [
|
|
638
|
+
persist({
|
|
639
|
+
adapter: asyncStorageAdapter(AsyncStorage),
|
|
640
|
+
key: 'app-state',
|
|
641
|
+
}),
|
|
642
|
+
],
|
|
643
|
+
})
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
### Custom Adapter
|
|
647
|
+
|
|
648
|
+
Implement the `PersistAdapter` interface with 3 methods. Each method can return a value synchronously or a `Promise`:
|
|
649
|
+
|
|
650
|
+
```typescript
|
|
651
|
+
import type { PersistAdapter } from 'shared-state-bridge'
|
|
652
|
+
|
|
653
|
+
const customAdapter: PersistAdapter = {
|
|
654
|
+
getItem: (key: string) => string | null | Promise<string | null>
|
|
655
|
+
setItem: (key: string, value: string) => void | Promise<void>
|
|
656
|
+
removeItem: (key: string) => void | Promise<void>
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
**Example — IndexedDB adapter:**
|
|
661
|
+
|
|
662
|
+
```typescript
|
|
663
|
+
const idbAdapter: PersistAdapter = {
|
|
664
|
+
getItem: async (key) => {
|
|
665
|
+
const db = await openDB()
|
|
666
|
+
return db.get('state-store', key)
|
|
667
|
+
},
|
|
668
|
+
setItem: async (key, value) => {
|
|
669
|
+
const db = await openDB()
|
|
670
|
+
await db.put('state-store', value, key)
|
|
671
|
+
},
|
|
672
|
+
removeItem: async (key) => {
|
|
673
|
+
const db = await openDB()
|
|
674
|
+
await db.delete('state-store', key)
|
|
675
|
+
},
|
|
676
|
+
}
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
### Persistence Options Reference
|
|
680
|
+
|
|
681
|
+
```typescript
|
|
682
|
+
persist({
|
|
683
|
+
// REQUIRED
|
|
684
|
+
adapter: PersistAdapter, // Storage backend
|
|
685
|
+
key: string, // Storage key name
|
|
686
|
+
|
|
687
|
+
// FILTERING (pick one or neither)
|
|
688
|
+
pick?: (keyof T)[], // Only persist these keys
|
|
689
|
+
omit?: (keyof T)[], // Exclude these keys
|
|
690
|
+
|
|
691
|
+
// PERFORMANCE
|
|
692
|
+
throttleMs?: number, // Throttle writes (default: 100ms)
|
|
693
|
+
|
|
694
|
+
// SERIALIZATION
|
|
695
|
+
serialize?: (state) => string, // Custom serializer (default: JSON.stringify)
|
|
696
|
+
deserialize?: (raw) => unknown, // Custom deserializer (default: JSON.parse)
|
|
697
|
+
|
|
698
|
+
// VERSIONING
|
|
699
|
+
version?: number, // Schema version (default: 0)
|
|
700
|
+
migrate?: (persisted, oldVersion) => Partial<T>, // Migration function
|
|
701
|
+
})
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
| Option | Type | Default | Description |
|
|
705
|
+
|---|---|---|---|
|
|
706
|
+
| `adapter` | `PersistAdapter` | *required* | The storage backend to use |
|
|
707
|
+
| `key` | `string` | *required* | Key under which state is stored |
|
|
708
|
+
| `pick` | `(keyof T)[]` | `undefined` | Whitelist: only persist these state keys |
|
|
709
|
+
| `omit` | `(keyof T)[]` | `undefined` | Blacklist: exclude these state keys from persistence |
|
|
710
|
+
| `throttleMs` | `number` | `100` | Minimum interval between writes (ms). Prevents excessive writes during rapid state changes. Trailing writes are guaranteed. |
|
|
711
|
+
| `serialize` | `(state) => string` | `JSON.stringify` | Custom serialization function |
|
|
712
|
+
| `deserialize` | `(raw) => unknown` | `JSON.parse` | Custom deserialization function |
|
|
713
|
+
| `version` | `number` | `0` | Schema version number, stored alongside persisted state |
|
|
714
|
+
| `migrate` | `(persisted, oldVersion) => Partial<T>` | `undefined` | Called when persisted version differs from current version |
|
|
715
|
+
|
|
716
|
+
### Schema Migrations
|
|
717
|
+
|
|
718
|
+
When your state shape changes between app versions, use `version` and `migrate` to handle the transition:
|
|
719
|
+
|
|
720
|
+
```typescript
|
|
721
|
+
// Version 1 state: { theme: 'light' }
|
|
722
|
+
// Version 2 state: { theme: 'light', locale: 'en' }
|
|
723
|
+
|
|
724
|
+
const bridge = createBridge({
|
|
725
|
+
name: 'app',
|
|
726
|
+
initialState: { theme: 'light', locale: 'en' },
|
|
727
|
+
plugins: [
|
|
728
|
+
persist({
|
|
729
|
+
adapter: localStorageAdapter,
|
|
730
|
+
key: 'app-state',
|
|
731
|
+
version: 2,
|
|
732
|
+
migrate: (persisted, oldVersion) => {
|
|
733
|
+
if (oldVersion === 1) {
|
|
734
|
+
// Add the new 'locale' field with a default value
|
|
735
|
+
return { ...(persisted as object), locale: 'en' } as Partial<AppState>
|
|
736
|
+
}
|
|
737
|
+
return persisted as Partial<AppState>
|
|
738
|
+
},
|
|
739
|
+
}),
|
|
740
|
+
],
|
|
741
|
+
})
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
**Migration behavior:**
|
|
745
|
+
- If persisted version matches current version: hydrate normally
|
|
746
|
+
- If versions differ AND `migrate` is provided: call `migrate(persisted, oldVersion)` and use the result
|
|
747
|
+
- If versions differ AND `migrate` is NOT provided: discard persisted data entirely and start fresh
|
|
748
|
+
|
|
749
|
+
### Storage Envelope Format
|
|
750
|
+
|
|
751
|
+
The persist plugin stores data in this format:
|
|
752
|
+
|
|
753
|
+
```json
|
|
754
|
+
{
|
|
755
|
+
"state": { "theme": "dark", "locale": "en" },
|
|
756
|
+
"version": 2
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
### Testing with memoryAdapter
|
|
761
|
+
|
|
762
|
+
The `memoryAdapter()` factory creates an in-memory storage backend. Perfect for tests and SSR:
|
|
763
|
+
|
|
764
|
+
```typescript
|
|
765
|
+
import { memoryAdapter } from 'shared-state-bridge/persist'
|
|
766
|
+
|
|
767
|
+
// Each call creates a fresh, isolated store
|
|
768
|
+
const adapter = memoryAdapter()
|
|
769
|
+
|
|
770
|
+
const bridge = createBridge({
|
|
771
|
+
name: 'test',
|
|
772
|
+
initialState: { count: 0 },
|
|
773
|
+
plugins: [persist({ adapter, key: 'test' })],
|
|
774
|
+
})
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
---
|
|
778
|
+
|
|
779
|
+
## Real-Time Sync (WebSocket)
|
|
780
|
+
|
|
781
|
+
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.
|
|
782
|
+
|
|
783
|
+
> **Important:** This is a client-side plugin only. You provide your own WebSocket server URL. A minimal example server is included below.
|
|
784
|
+
|
|
785
|
+
### Basic Usage
|
|
786
|
+
|
|
787
|
+
```typescript
|
|
788
|
+
import { createBridge } from 'shared-state-bridge'
|
|
789
|
+
import { sync } from 'shared-state-bridge/sync'
|
|
790
|
+
|
|
791
|
+
const bridge = createBridge({
|
|
792
|
+
name: 'app',
|
|
793
|
+
initialState: { theme: 'light', count: 0, localDraft: '' },
|
|
794
|
+
plugins: [
|
|
795
|
+
sync({
|
|
796
|
+
url: 'wss://your-server.com/sync',
|
|
797
|
+
channel: 'room-123',
|
|
798
|
+
}),
|
|
799
|
+
],
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
// State changes are now synced across all connected clients
|
|
803
|
+
bridge.setState({ theme: 'dark' }) // -> sent to all other clients in room-123
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
### How It Works
|
|
807
|
+
|
|
808
|
+
```
|
|
809
|
+
App A (Next.js) WebSocket Server App B (React Native)
|
|
810
|
+
───────────────── ────────────────── ─────────────────────
|
|
811
|
+
bridge.setState()
|
|
812
|
+
│
|
|
813
|
+
├─ onStateChange()
|
|
814
|
+
│ ├─ isApplyingRemote? skip (echo guard)
|
|
815
|
+
│ └─ filterState() ──► send({ type: "state" }) ──► broadcast to channel
|
|
816
|
+
│ │ │
|
|
817
|
+
│ │ ◄──────┘
|
|
818
|
+
│ │ onMessage()
|
|
819
|
+
│ │ ├─ same clientId? skip
|
|
820
|
+
│ │ ├─ resolve(local, remote)
|
|
821
|
+
│ │ └─ bridge.setState(merged)
|
|
822
|
+
│ │ └─ isApplyingRemote = true
|
|
823
|
+
│ │ (prevents re-broadcast)
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
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.
|
|
827
|
+
|
|
828
|
+
### Selective Sync (pick / omit)
|
|
829
|
+
|
|
830
|
+
Same as persistence — only sync the keys you need:
|
|
831
|
+
|
|
832
|
+
```typescript
|
|
833
|
+
sync({
|
|
834
|
+
url: 'wss://your-server.com/sync',
|
|
835
|
+
channel: 'room-1',
|
|
836
|
+
pick: ['theme', 'count'], // only sync these keys
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
// OR
|
|
840
|
+
|
|
841
|
+
sync({
|
|
842
|
+
url: 'wss://your-server.com/sync',
|
|
843
|
+
channel: 'room-1',
|
|
844
|
+
omit: ['localDraft'], // sync everything except these
|
|
845
|
+
})
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
### Custom Conflict Resolution
|
|
849
|
+
|
|
850
|
+
By default, incoming remote state is merged directly (last-write-wins). You can provide a custom `resolve` function:
|
|
851
|
+
|
|
852
|
+
```typescript
|
|
853
|
+
sync({
|
|
854
|
+
url: 'wss://your-server.com/sync',
|
|
855
|
+
channel: 'room-1',
|
|
856
|
+
resolve: (localState, remoteState) => {
|
|
857
|
+
// Custom logic: take the higher count, but always accept remote theme
|
|
858
|
+
return {
|
|
859
|
+
count: Math.max(localState.count, remoteState.count ?? 0),
|
|
860
|
+
theme: remoteState.theme ?? localState.theme,
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
})
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
The `resolve` function receives the full local state and the incoming remote partial state, and should return the partial state to apply.
|
|
867
|
+
|
|
868
|
+
### Reconnection
|
|
869
|
+
|
|
870
|
+
Auto-reconnect is enabled by default with exponential backoff:
|
|
871
|
+
|
|
872
|
+
```typescript
|
|
873
|
+
sync({
|
|
874
|
+
url: 'wss://your-server.com/sync',
|
|
875
|
+
channel: 'room-1',
|
|
876
|
+
reconnect: true, // default: true
|
|
877
|
+
reconnectInterval: 1000, // base interval in ms (default: 1000)
|
|
878
|
+
maxReconnectInterval: 30000, // cap in ms (default: 30000)
|
|
879
|
+
maxReconnectAttempts: Infinity, // default: Infinity
|
|
880
|
+
})
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
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.
|
|
884
|
+
|
|
885
|
+
### Callbacks
|
|
886
|
+
|
|
887
|
+
```typescript
|
|
888
|
+
sync({
|
|
889
|
+
url: 'wss://your-server.com/sync',
|
|
890
|
+
channel: 'room-1',
|
|
891
|
+
onConnect: () => console.log('Connected to sync server'),
|
|
892
|
+
onDisconnect: () => console.log('Disconnected from sync server'),
|
|
893
|
+
onError: (error) => console.error('Sync error:', error),
|
|
894
|
+
})
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
### Sync Options Reference
|
|
898
|
+
|
|
899
|
+
| Option | Type | Default | Description |
|
|
900
|
+
|---|---|---|---|
|
|
901
|
+
| `url` | `string` | *required* | WebSocket server URL (`wss://...`) |
|
|
902
|
+
| `channel` | `string` | *required* | Channel/room name to join |
|
|
903
|
+
| `pick` | `(keyof T)[]` | — | Only sync these keys |
|
|
904
|
+
| `omit` | `(keyof T)[]` | — | Exclude these keys from sync |
|
|
905
|
+
| `throttleMs` | `number` | `50` | Throttle outbound messages (ms) |
|
|
906
|
+
| `reconnect` | `boolean` | `true` | Auto-reconnect on disconnect |
|
|
907
|
+
| `reconnectInterval` | `number` | `1000` | Base reconnect interval (ms) |
|
|
908
|
+
| `maxReconnectInterval` | `number` | `30000` | Max reconnect interval cap (ms) |
|
|
909
|
+
| `maxReconnectAttempts` | `number` | `Infinity` | Max reconnect attempts |
|
|
910
|
+
| `onConnect` | `() => void` | — | Called on successful connection |
|
|
911
|
+
| `onDisconnect` | `() => void` | — | Called on disconnection |
|
|
912
|
+
| `onError` | `(error) => void` | — | Called on WebSocket error |
|
|
913
|
+
| `resolve` | `(local, remote) => Partial<T>` | — | Custom conflict resolver |
|
|
914
|
+
|
|
915
|
+
### Wire Protocol
|
|
916
|
+
|
|
917
|
+
All messages are JSON over WebSocket. The protocol is simple and easy to implement on any server.
|
|
918
|
+
|
|
919
|
+
**Client -> Server:**
|
|
920
|
+
|
|
921
|
+
```typescript
|
|
922
|
+
// Join a channel
|
|
923
|
+
{ type: "join", channel: "room-1", clientId: "abc123" }
|
|
924
|
+
|
|
925
|
+
// Send state update
|
|
926
|
+
{ type: "state", channel: "room-1", clientId: "abc123", state: { count: 5 }, timestamp: 1700000000000 }
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
**Server -> Client:**
|
|
930
|
+
|
|
931
|
+
```typescript
|
|
932
|
+
// Relay state from another client
|
|
933
|
+
{ type: "state", channel: "room-1", clientId: "other456", state: { count: 5 }, timestamp: 1700000000000 }
|
|
934
|
+
|
|
935
|
+
// Send full state (e.g., on initial join for late-joiners)
|
|
936
|
+
{ type: "full_state", channel: "room-1", state: { count: 5, theme: "dark" }, timestamp: 1700000000000 }
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
### Example WebSocket Server (Node.js)
|
|
940
|
+
|
|
941
|
+
A minimal relay server that broadcasts state to all clients in a channel:
|
|
942
|
+
|
|
943
|
+
```javascript
|
|
944
|
+
import { WebSocketServer } from 'ws'
|
|
945
|
+
|
|
946
|
+
const wss = new WebSocketServer({ port: 8080 })
|
|
947
|
+
|
|
948
|
+
// channel -> Set<WebSocket>
|
|
949
|
+
const channels = new Map()
|
|
950
|
+
|
|
951
|
+
wss.on('connection', (ws) => {
|
|
952
|
+
let clientChannel = null
|
|
953
|
+
|
|
954
|
+
ws.on('message', (raw) => {
|
|
955
|
+
const msg = JSON.parse(raw)
|
|
956
|
+
|
|
957
|
+
if (msg.type === 'join') {
|
|
958
|
+
clientChannel = msg.channel
|
|
959
|
+
if (!channels.has(clientChannel)) {
|
|
960
|
+
channels.set(clientChannel, new Set())
|
|
961
|
+
}
|
|
962
|
+
channels.get(clientChannel).add(ws)
|
|
963
|
+
return
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if (msg.type === 'state' && clientChannel) {
|
|
967
|
+
// Broadcast to all OTHER clients in the same channel
|
|
968
|
+
for (const client of channels.get(clientChannel) || []) {
|
|
969
|
+
if (client !== ws && client.readyState === 1) {
|
|
970
|
+
client.send(JSON.stringify(msg))
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
ws.on('close', () => {
|
|
977
|
+
if (clientChannel && channels.has(clientChannel)) {
|
|
978
|
+
channels.get(clientChannel).delete(ws)
|
|
979
|
+
if (channels.get(clientChannel).size === 0) {
|
|
980
|
+
channels.delete(clientChannel)
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
})
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
console.log('Sync server running on ws://localhost:8080')
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
> **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.
|
|
990
|
+
|
|
991
|
+
---
|
|
992
|
+
|
|
993
|
+
## Plugins
|
|
994
|
+
|
|
995
|
+
Bridges support a plugin system via lifecycle hooks. The `persist` and `sync` plugins are the built-in examples, but you can write your own.
|
|
996
|
+
|
|
997
|
+
### Plugin Lifecycle
|
|
998
|
+
|
|
999
|
+
```
|
|
1000
|
+
createBridge() called
|
|
1001
|
+
│
|
|
1002
|
+
├── 1. State initialized from initialState
|
|
1003
|
+
├── 2. Bridge registered in global registry
|
|
1004
|
+
├── 3. plugin.onInit(bridgeApi) <-- Bridge is fully constructed
|
|
1005
|
+
│
|
|
1006
|
+
▼
|
|
1007
|
+
bridge.setState() called
|
|
1008
|
+
│
|
|
1009
|
+
├── 4. State updated (merge or replace)
|
|
1010
|
+
├── 5. Listeners notified
|
|
1011
|
+
├── 6. plugin.onStateChange(state, prev) <-- After listeners
|
|
1012
|
+
│
|
|
1013
|
+
▼
|
|
1014
|
+
bridge.destroy() called
|
|
1015
|
+
│
|
|
1016
|
+
├── 7. Bridge removed from registry
|
|
1017
|
+
├── 8. plugin.onDestroy() <-- Cleanup
|
|
1018
|
+
└── 9. All listeners cleared
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
### Plugin Interface
|
|
1022
|
+
|
|
1023
|
+
```typescript
|
|
1024
|
+
interface BridgePlugin<T extends State> {
|
|
1025
|
+
/** Unique plugin name (for debugging) */
|
|
1026
|
+
name: string
|
|
1027
|
+
|
|
1028
|
+
/** Called once after the bridge is fully initialized and registered */
|
|
1029
|
+
onInit?: (bridge: BridgeApi<T>) => void
|
|
1030
|
+
|
|
1031
|
+
/** Called after every state change, after listeners are notified */
|
|
1032
|
+
onStateChange?: (state: T, previousState: T) => void
|
|
1033
|
+
|
|
1034
|
+
/** Called when bridge.destroy() is invoked, before listeners are cleared */
|
|
1035
|
+
onDestroy?: () => void
|
|
1036
|
+
}
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
### Writing a Custom Plugin
|
|
1040
|
+
|
|
1041
|
+
**Example — Logger Plugin:**
|
|
1042
|
+
|
|
1043
|
+
```typescript
|
|
1044
|
+
import type { BridgePlugin } from 'shared-state-bridge'
|
|
1045
|
+
|
|
1046
|
+
function createLoggerPlugin<T extends State>(options?: {
|
|
1047
|
+
collapsed?: boolean
|
|
1048
|
+
}): BridgePlugin<T> {
|
|
1049
|
+
return {
|
|
1050
|
+
name: 'logger',
|
|
1051
|
+
onInit: (bridge) => {
|
|
1052
|
+
console.log(`[logger] Bridge "${bridge.getName()}" initialized with:`, bridge.getState())
|
|
1053
|
+
},
|
|
1054
|
+
onStateChange: (state, previousState) => {
|
|
1055
|
+
const method = options?.collapsed ? console.groupCollapsed : console.group
|
|
1056
|
+
method('[logger] State change')
|
|
1057
|
+
console.log('Previous:', previousState)
|
|
1058
|
+
console.log('Current:', state)
|
|
1059
|
+
console.groupEnd()
|
|
1060
|
+
},
|
|
1061
|
+
onDestroy: () => {
|
|
1062
|
+
console.log('[logger] Bridge destroyed')
|
|
1063
|
+
},
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Usage
|
|
1068
|
+
const bridge = createBridge({
|
|
1069
|
+
name: 'app',
|
|
1070
|
+
initialState: { count: 0 },
|
|
1071
|
+
plugins: [createLoggerPlugin({ collapsed: true })],
|
|
1072
|
+
})
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
**Example — Validation Plugin:**
|
|
1076
|
+
|
|
1077
|
+
```typescript
|
|
1078
|
+
function createValidationPlugin<T extends State>(
|
|
1079
|
+
validate: (state: T) => boolean,
|
|
1080
|
+
errorMessage?: string
|
|
1081
|
+
): BridgePlugin<T> {
|
|
1082
|
+
return {
|
|
1083
|
+
name: 'validation',
|
|
1084
|
+
onStateChange: (state) => {
|
|
1085
|
+
if (!validate(state)) {
|
|
1086
|
+
console.error(errorMessage ?? '[validation] Invalid state:', state)
|
|
1087
|
+
}
|
|
1088
|
+
},
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Usage
|
|
1093
|
+
const bridge = createBridge({
|
|
1094
|
+
name: 'counter',
|
|
1095
|
+
initialState: { count: 0 },
|
|
1096
|
+
plugins: [
|
|
1097
|
+
createValidationPlugin(
|
|
1098
|
+
(s) => s.count >= 0,
|
|
1099
|
+
'Count cannot be negative!'
|
|
1100
|
+
),
|
|
1101
|
+
],
|
|
1102
|
+
})
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
**Combining multiple plugins:**
|
|
1106
|
+
|
|
1107
|
+
```typescript
|
|
1108
|
+
const bridge = createBridge({
|
|
1109
|
+
name: 'app',
|
|
1110
|
+
initialState: { theme: 'light', count: 0 },
|
|
1111
|
+
plugins: [
|
|
1112
|
+
persist({ adapter: localStorageAdapter, key: 'app', pick: ['theme'] }),
|
|
1113
|
+
createLoggerPlugin(),
|
|
1114
|
+
createValidationPlugin((s) => typeof s.count === 'number'),
|
|
1115
|
+
],
|
|
1116
|
+
})
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
Plugins execute in array order — `onInit` and `onStateChange` are called on plugin 1, then plugin 2, then plugin 3.
|
|
1120
|
+
|
|
1121
|
+
---
|
|
1122
|
+
|
|
1123
|
+
## TypeScript
|
|
1124
|
+
|
|
1125
|
+
Full type inference throughout the API:
|
|
1126
|
+
|
|
1127
|
+
```typescript
|
|
1128
|
+
interface AppState {
|
|
1129
|
+
count: number
|
|
1130
|
+
theme: 'light' | 'dark'
|
|
1131
|
+
user: { name: string } | null
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// createBridge infers T from initialState (or use explicit generic)
|
|
1135
|
+
const bridge = createBridge<AppState>({
|
|
1136
|
+
name: 'app',
|
|
1137
|
+
initialState: { count: 0, theme: 'light', user: null },
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1140
|
+
// setState is fully typed
|
|
1141
|
+
bridge.setState({ count: 1 }) // OK
|
|
1142
|
+
bridge.setState({ count: 'string' }) // Type error: string is not number
|
|
1143
|
+
bridge.setState({ unknown: true }) // Type error: unknown key
|
|
1144
|
+
|
|
1145
|
+
// Updater function receives correctly typed state
|
|
1146
|
+
bridge.setState(prev => ({
|
|
1147
|
+
count: prev.count + 1, // prev is AppState
|
|
1148
|
+
}))
|
|
1149
|
+
|
|
1150
|
+
// Selectors infer return type
|
|
1151
|
+
const count = useBridgeState(bridge, s => s.count)
|
|
1152
|
+
// ^? number
|
|
1153
|
+
|
|
1154
|
+
const theme = useBridgeState(bridge, s => s.theme)
|
|
1155
|
+
// ^? 'light' | 'dark'
|
|
1156
|
+
|
|
1157
|
+
// getBridge with type parameter
|
|
1158
|
+
const b = getBridge<AppState>('app')
|
|
1159
|
+
b.getState().theme // 'light' | 'dark'
|
|
1160
|
+
b.getState().unknown // Type error
|
|
1161
|
+
|
|
1162
|
+
// Subscribe selector is typed
|
|
1163
|
+
bridge.subscribe(
|
|
1164
|
+
s => s.user, // selector returns { name: string } | null
|
|
1165
|
+
(user, prevUser) => { // user and prevUser are { name: string } | null
|
|
1166
|
+
console.log(user?.name)
|
|
1167
|
+
}
|
|
1168
|
+
)
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
### Type Exports
|
|
1172
|
+
|
|
1173
|
+
All types are exported for use in your own code:
|
|
1174
|
+
|
|
1175
|
+
```typescript
|
|
1176
|
+
import type {
|
|
1177
|
+
State, // Record<string, unknown>
|
|
1178
|
+
BridgeApi, // Bridge instance type
|
|
1179
|
+
BridgeConfig, // createBridge config
|
|
1180
|
+
BridgePlugin, // Plugin interface
|
|
1181
|
+
PersistAdapter, // Storage adapter interface
|
|
1182
|
+
PersistOptions, // persist() config
|
|
1183
|
+
SetState, // setState signature
|
|
1184
|
+
Subscribe, // subscribe signature
|
|
1185
|
+
Listener, // Full-state listener type
|
|
1186
|
+
SelectorListener, // Selector listener type
|
|
1187
|
+
Selector, // Selector function type
|
|
1188
|
+
EqualityFn, // Equality function type
|
|
1189
|
+
} from 'shared-state-bridge'
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
---
|
|
1193
|
+
|
|
1194
|
+
## Monorepo Setup (Turborepo / Nx)
|
|
1195
|
+
|
|
1196
|
+
### 1. Add as a Shared Dependency
|
|
1197
|
+
|
|
1198
|
+
Add `shared-state-bridge` to a shared/common package in your monorepo:
|
|
1199
|
+
|
|
1200
|
+
```jsonc
|
|
1201
|
+
// packages/shared/package.json
|
|
1202
|
+
{
|
|
1203
|
+
"name": "@myorg/shared",
|
|
1204
|
+
"dependencies": {
|
|
1205
|
+
"shared-state-bridge": "^1.0.0"
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
### 2. Define Bridges in the Shared Package
|
|
1211
|
+
|
|
1212
|
+
```typescript
|
|
1213
|
+
// packages/shared/src/bridges.ts
|
|
1214
|
+
import { createBridge } from 'shared-state-bridge'
|
|
1215
|
+
|
|
1216
|
+
// --- Auth Bridge ---
|
|
1217
|
+
export interface AuthState {
|
|
1218
|
+
user: { id: string; name: string; email: string } | null
|
|
1219
|
+
token: string | null
|
|
1220
|
+
isLoading: boolean
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
export const authBridge = createBridge<AuthState>({
|
|
1224
|
+
name: 'auth',
|
|
1225
|
+
initialState: { user: null, token: null, isLoading: false },
|
|
1226
|
+
})
|
|
1227
|
+
|
|
1228
|
+
// --- UI Bridge ---
|
|
1229
|
+
export interface UIState {
|
|
1230
|
+
sidebarOpen: boolean
|
|
1231
|
+
modal: string | null
|
|
1232
|
+
toasts: Array<{ id: string; message: string }>
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
export const uiBridge = createBridge<UIState>({
|
|
1236
|
+
name: 'ui',
|
|
1237
|
+
initialState: { sidebarOpen: false, modal: null, toasts: [] },
|
|
1238
|
+
})
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
### 3. Use from Any Package
|
|
1242
|
+
|
|
1243
|
+
**Web (Next.js):**
|
|
1244
|
+
|
|
1245
|
+
```tsx
|
|
1246
|
+
// packages/web/src/components/Header.tsx
|
|
1247
|
+
import { useBridgeState } from 'shared-state-bridge/react'
|
|
1248
|
+
import { getBridge } from 'shared-state-bridge'
|
|
1249
|
+
import type { AuthState } from '@myorg/shared'
|
|
1250
|
+
|
|
1251
|
+
const auth = getBridge<AuthState>('auth')
|
|
1252
|
+
|
|
1253
|
+
function Header() {
|
|
1254
|
+
const user = useBridgeState(auth, s => s.user)
|
|
1255
|
+
|
|
1256
|
+
return (
|
|
1257
|
+
<header>
|
|
1258
|
+
<span>{user?.name ?? 'Guest'}</span>
|
|
1259
|
+
</header>
|
|
1260
|
+
)
|
|
1261
|
+
}
|
|
1262
|
+
```
|
|
1263
|
+
|
|
1264
|
+
**Mobile (React Native):**
|
|
1265
|
+
|
|
1266
|
+
```tsx
|
|
1267
|
+
// packages/mobile/src/screens/Profile.tsx
|
|
1268
|
+
import { useBridgeState } from 'shared-state-bridge/react'
|
|
1269
|
+
import { getBridge } from 'shared-state-bridge'
|
|
1270
|
+
import type { AuthState } from '@myorg/shared'
|
|
1271
|
+
|
|
1272
|
+
const auth = getBridge<AuthState>('auth')
|
|
1273
|
+
|
|
1274
|
+
function ProfileScreen() {
|
|
1275
|
+
const user = useBridgeState(auth, s => s.user)
|
|
1276
|
+
|
|
1277
|
+
return (
|
|
1278
|
+
<View>
|
|
1279
|
+
<Text>{user?.name}</Text>
|
|
1280
|
+
<Text>{user?.email}</Text>
|
|
1281
|
+
</View>
|
|
1282
|
+
)
|
|
1283
|
+
}
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
**Service Package (No React):**
|
|
1287
|
+
|
|
1288
|
+
```typescript
|
|
1289
|
+
// packages/analytics/src/tracker.ts
|
|
1290
|
+
import { getBridge } from 'shared-state-bridge'
|
|
1291
|
+
import type { AuthState } from '@myorg/shared'
|
|
1292
|
+
|
|
1293
|
+
const auth = getBridge<AuthState>('auth')
|
|
1294
|
+
|
|
1295
|
+
auth.subscribe(
|
|
1296
|
+
s => s.user,
|
|
1297
|
+
(user) => {
|
|
1298
|
+
if (user) {
|
|
1299
|
+
analytics.identify(user.id, { name: user.name })
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
)
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
### Project Structure Example
|
|
1306
|
+
|
|
1307
|
+
```
|
|
1308
|
+
my-monorepo/
|
|
1309
|
+
├── packages/
|
|
1310
|
+
│ ├── shared/ # Bridge definitions + types
|
|
1311
|
+
│ │ └── src/bridges.ts
|
|
1312
|
+
│ ├── web/ # Next.js app
|
|
1313
|
+
│ │ └── src/components/Header.tsx
|
|
1314
|
+
│ ├── mobile/ # React Native app
|
|
1315
|
+
│ │ └── src/screens/Profile.tsx
|
|
1316
|
+
│ └── analytics/ # Service package (no React)
|
|
1317
|
+
│ └── src/tracker.ts
|
|
1318
|
+
├── turbo.json / nx.json
|
|
1319
|
+
└── package.json
|
|
1320
|
+
```
|
|
1321
|
+
|
|
1322
|
+
---
|
|
1323
|
+
|
|
1324
|
+
## API Reference
|
|
1325
|
+
|
|
1326
|
+
### Core API
|
|
1327
|
+
|
|
1328
|
+
#### `createBridge<T>(config): BridgeApi<T>`
|
|
1329
|
+
|
|
1330
|
+
Creates and registers a new bridge store.
|
|
1331
|
+
|
|
1332
|
+
| Parameter | Type | Description |
|
|
1333
|
+
|---|---|---|
|
|
1334
|
+
| `config.name` | `string` | Unique name for the global registry |
|
|
1335
|
+
| `config.initialState` | `T` | Initial state object |
|
|
1336
|
+
| `config.plugins` | `BridgePlugin<T>[]` | Optional plugins array |
|
|
1337
|
+
|
|
1338
|
+
**Returns:** `BridgeApi<T>` instance
|
|
1339
|
+
|
|
1340
|
+
**Throws:** If a bridge with the same name already exists
|
|
1341
|
+
|
|
1342
|
+
---
|
|
1343
|
+
|
|
1344
|
+
#### `getBridge<T>(name): BridgeApi<T>`
|
|
1345
|
+
|
|
1346
|
+
Retrieves an existing bridge from the global registry.
|
|
1347
|
+
|
|
1348
|
+
| Parameter | Type | Description |
|
|
1349
|
+
|---|---|---|
|
|
1350
|
+
| `name` | `string` | The bridge name |
|
|
1351
|
+
|
|
1352
|
+
**Returns:** `BridgeApi<T>` — the exact same instance that was created
|
|
1353
|
+
|
|
1354
|
+
**Throws:** If no bridge with that name exists
|
|
1355
|
+
|
|
1356
|
+
---
|
|
1357
|
+
|
|
1358
|
+
#### `hasBridge(name): boolean`
|
|
1359
|
+
|
|
1360
|
+
Checks if a bridge exists in the registry without throwing.
|
|
1361
|
+
|
|
1362
|
+
---
|
|
1363
|
+
|
|
1364
|
+
#### `listBridges(): string[]`
|
|
1365
|
+
|
|
1366
|
+
Returns an array of all registered bridge names.
|
|
1367
|
+
|
|
1368
|
+
---
|
|
1369
|
+
|
|
1370
|
+
### BridgeApi Instance Methods
|
|
1371
|
+
|
|
1372
|
+
These are the methods available on the object returned by `createBridge()`.
|
|
1373
|
+
|
|
1374
|
+
#### `bridge.getState(): T`
|
|
1375
|
+
|
|
1376
|
+
Returns the current state snapshot. Synchronous.
|
|
1377
|
+
|
|
1378
|
+
---
|
|
1379
|
+
|
|
1380
|
+
#### `bridge.setState(partial): void`
|
|
1381
|
+
|
|
1382
|
+
Updates state by shallow-merging `partial` into current state.
|
|
1383
|
+
|
|
1384
|
+
```typescript
|
|
1385
|
+
// Object form
|
|
1386
|
+
bridge.setState({ count: 1 })
|
|
1387
|
+
|
|
1388
|
+
// Updater function form
|
|
1389
|
+
bridge.setState(prev => ({ count: prev.count + 1 }))
|
|
1390
|
+
```
|
|
1391
|
+
|
|
1392
|
+
---
|
|
1393
|
+
|
|
1394
|
+
#### `bridge.setState(state, true): void`
|
|
1395
|
+
|
|
1396
|
+
Replaces the entire state (no merge).
|
|
1397
|
+
|
|
1398
|
+
```typescript
|
|
1399
|
+
bridge.setState({ count: 0, theme: 'light' }, true)
|
|
1400
|
+
```
|
|
1401
|
+
|
|
1402
|
+
---
|
|
1403
|
+
|
|
1404
|
+
#### `bridge.subscribe(listener): () => void`
|
|
1405
|
+
|
|
1406
|
+
Subscribes to all state changes. Returns an unsubscribe function.
|
|
1407
|
+
|
|
1408
|
+
```typescript
|
|
1409
|
+
const unsub = bridge.subscribe((state, previousState) => { ... })
|
|
1410
|
+
unsub() // stop listening
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
---
|
|
1414
|
+
|
|
1415
|
+
#### `bridge.subscribe(selector, listener, options?): () => void`
|
|
1416
|
+
|
|
1417
|
+
Subscribes with a selector. Listener only fires when the selected value changes.
|
|
1418
|
+
|
|
1419
|
+
```typescript
|
|
1420
|
+
const unsub = bridge.subscribe(
|
|
1421
|
+
s => s.count,
|
|
1422
|
+
(count, prevCount) => { ... },
|
|
1423
|
+
{ equalityFn: Object.is, fireImmediately: false }
|
|
1424
|
+
)
|
|
1425
|
+
```
|
|
1426
|
+
|
|
1427
|
+
---
|
|
1428
|
+
|
|
1429
|
+
#### `bridge.getInitialState(): T`
|
|
1430
|
+
|
|
1431
|
+
Returns the original initial state (never changes).
|
|
1432
|
+
|
|
1433
|
+
---
|
|
1434
|
+
|
|
1435
|
+
#### `bridge.getName(): string`
|
|
1436
|
+
|
|
1437
|
+
Returns the bridge's registered name.
|
|
1438
|
+
|
|
1439
|
+
---
|
|
1440
|
+
|
|
1441
|
+
#### `bridge.destroy(): void`
|
|
1442
|
+
|
|
1443
|
+
Removes from registry, calls plugin `onDestroy`, clears listeners. Idempotent.
|
|
1444
|
+
|
|
1445
|
+
---
|
|
1446
|
+
|
|
1447
|
+
### React API
|
|
1448
|
+
|
|
1449
|
+
Import from `shared-state-bridge/react`.
|
|
1450
|
+
|
|
1451
|
+
#### `<BridgeProvider bridge={bridge}>`
|
|
1452
|
+
|
|
1453
|
+
Context provider. Makes `bridge` available to `useBridge()` and `useBridgeState(selector)`.
|
|
1454
|
+
|
|
1455
|
+
---
|
|
1456
|
+
|
|
1457
|
+
#### `useBridge<T>(): BridgeApi<T>`
|
|
1458
|
+
|
|
1459
|
+
Returns bridge from context. Throws outside `BridgeProvider`.
|
|
1460
|
+
|
|
1461
|
+
---
|
|
1462
|
+
|
|
1463
|
+
#### `useBridgeState(selector, options?): U`
|
|
1464
|
+
|
|
1465
|
+
Subscribes to state from context bridge.
|
|
1466
|
+
|
|
1467
|
+
#### `useBridgeState(bridge, selector, options?): U`
|
|
1468
|
+
|
|
1469
|
+
Subscribes to state from a direct bridge reference.
|
|
1470
|
+
|
|
1471
|
+
#### `useBridgeState(bridge): T`
|
|
1472
|
+
|
|
1473
|
+
Subscribes to the full state.
|
|
1474
|
+
|
|
1475
|
+
**Options:**
|
|
1476
|
+
|
|
1477
|
+
| Option | Type | Default | Description |
|
|
1478
|
+
|---|---|---|---|
|
|
1479
|
+
| `shallow` | `boolean` | `false` | Use shallow equality to prevent re-renders from object selectors |
|
|
1480
|
+
|
|
1481
|
+
---
|
|
1482
|
+
|
|
1483
|
+
### Persist API
|
|
1484
|
+
|
|
1485
|
+
Import from `shared-state-bridge/persist`.
|
|
1486
|
+
|
|
1487
|
+
#### `persist<T>(options): BridgePlugin<T>`
|
|
1488
|
+
|
|
1489
|
+
Creates a persistence plugin. See [Persistence Options Reference](#persistence-options-reference) for full options.
|
|
1490
|
+
|
|
1491
|
+
---
|
|
1492
|
+
|
|
1493
|
+
#### `localStorageAdapter: PersistAdapter`
|
|
1494
|
+
|
|
1495
|
+
Pre-built adapter for `window.localStorage`. Safe in SSR (returns `null`).
|
|
1496
|
+
|
|
1497
|
+
---
|
|
1498
|
+
|
|
1499
|
+
#### `asyncStorageAdapter(storage): PersistAdapter`
|
|
1500
|
+
|
|
1501
|
+
Factory that wraps a React Native `AsyncStorage` instance.
|
|
1502
|
+
|
|
1503
|
+
```typescript
|
|
1504
|
+
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
1505
|
+
const adapter = asyncStorageAdapter(AsyncStorage)
|
|
1506
|
+
```
|
|
1507
|
+
|
|
1508
|
+
---
|
|
1509
|
+
|
|
1510
|
+
#### `memoryAdapter(): PersistAdapter`
|
|
1511
|
+
|
|
1512
|
+
Creates an in-memory storage backend. Each call returns a fresh, isolated store.
|
|
1513
|
+
|
|
1514
|
+
---
|
|
1515
|
+
|
|
1516
|
+
### Sync API
|
|
1517
|
+
|
|
1518
|
+
#### `sync<T>(options: SyncOptions<T>): BridgePlugin<T>`
|
|
1519
|
+
|
|
1520
|
+
Creates a WebSocket sync plugin. Pass it in the `plugins` array of `createBridge()`.
|
|
1521
|
+
|
|
1522
|
+
```typescript
|
|
1523
|
+
import { sync } from 'shared-state-bridge/sync'
|
|
1524
|
+
|
|
1525
|
+
sync({
|
|
1526
|
+
url: 'wss://your-server.com/sync',
|
|
1527
|
+
channel: 'room-1',
|
|
1528
|
+
pick: ['theme'],
|
|
1529
|
+
throttleMs: 50,
|
|
1530
|
+
onConnect: () => console.log('connected'),
|
|
1531
|
+
})
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
See [Sync Options Reference](#sync-options-reference) for all available options.
|
|
1535
|
+
|
|
1536
|
+
#### `SyncConnection`
|
|
1537
|
+
|
|
1538
|
+
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.
|
|
1539
|
+
|
|
1540
|
+
```typescript
|
|
1541
|
+
import { SyncConnection } from 'shared-state-bridge/sync'
|
|
1542
|
+
|
|
1543
|
+
const conn = new SyncConnection({
|
|
1544
|
+
url: 'wss://your-server.com/sync',
|
|
1545
|
+
reconnect: true,
|
|
1546
|
+
maxReconnectAttempts: 10,
|
|
1547
|
+
reconnectInterval: 1000,
|
|
1548
|
+
maxReconnectInterval: 30000,
|
|
1549
|
+
onConnect: () => {},
|
|
1550
|
+
onDisconnect: () => {},
|
|
1551
|
+
onMessage: (msg) => {},
|
|
1552
|
+
onError: (err) => {},
|
|
1553
|
+
})
|
|
1554
|
+
|
|
1555
|
+
conn.connect()
|
|
1556
|
+
conn.send({ type: 'state', channel: 'room-1', clientId: 'abc', state: {}, timestamp: Date.now() })
|
|
1557
|
+
conn.destroy()
|
|
1558
|
+
```
|
|
1559
|
+
|
|
1560
|
+
---
|
|
1561
|
+
|
|
1562
|
+
### Types
|
|
1563
|
+
|
|
1564
|
+
All types are exported from the main entry point:
|
|
1565
|
+
|
|
1566
|
+
| Type | Description |
|
|
1567
|
+
|---|---|
|
|
1568
|
+
| `State` | Base state constraint: `Record<string, unknown>` |
|
|
1569
|
+
| `BridgeApi<T>` | Bridge instance interface with all methods |
|
|
1570
|
+
| `BridgeConfig<T>` | Configuration object for `createBridge` |
|
|
1571
|
+
| `BridgePlugin<T>` | Plugin interface with lifecycle hooks |
|
|
1572
|
+
| `PersistAdapter` | Storage adapter interface (3 methods) |
|
|
1573
|
+
| `PersistOptions<T>` | Full configuration for the `persist` plugin |
|
|
1574
|
+
| `SetState<T>` | Type signature for `bridge.setState` |
|
|
1575
|
+
| `Subscribe<T>` | Type signature for `bridge.subscribe` |
|
|
1576
|
+
| `Listener<T>` | `(state: T, previousState: T) => void` |
|
|
1577
|
+
| `SelectorListener<T, U>` | `(slice: U, previousSlice: U) => void` |
|
|
1578
|
+
| `SubscribeOptions<U>` | Options for selector-based subscribe |
|
|
1579
|
+
| `Selector<T, U>` | `(state: T) => U` |
|
|
1580
|
+
| `EqualityFn<U>` | `(a: U, b: U) => boolean` |
|
|
1581
|
+
| `SyncOptions<T>` | Configuration for the `sync` plugin |
|
|
1582
|
+
| `JoinMessage` | `{ type: "join", channel, clientId }` |
|
|
1583
|
+
| `StateMessage` | `{ type: "state", channel, clientId, state, timestamp }` |
|
|
1584
|
+
| `FullStateMessage` | `{ type: "full_state", channel, state, timestamp }` |
|
|
1585
|
+
| `OutboundMessage` | `JoinMessage \| StateMessage` |
|
|
1586
|
+
| `InboundMessage` | `StateMessage \| FullStateMessage` |
|
|
1587
|
+
|
|
1588
|
+
---
|
|
1589
|
+
|
|
1590
|
+
## Architecture Deep Dive
|
|
1591
|
+
|
|
1592
|
+
### State Update Flow
|
|
1593
|
+
|
|
1594
|
+
```
|
|
1595
|
+
bridge.setState({ count: 1 })
|
|
1596
|
+
│
|
|
1597
|
+
├── 1. Resolve partial: if function, call with current state
|
|
1598
|
+
│ nextPartial = isFunction(partial) ? partial(state) : partial
|
|
1599
|
+
│
|
|
1600
|
+
├── 2. Apply update:
|
|
1601
|
+
│ if (replace) state = nextPartial
|
|
1602
|
+
│ else state = Object.assign({}, state, nextPartial) // new reference
|
|
1603
|
+
│
|
|
1604
|
+
├── 3. Check: Object.is(state, previousState)?
|
|
1605
|
+
│ YES → return (no notifications)
|
|
1606
|
+
│ NO → continue
|
|
1607
|
+
│
|
|
1608
|
+
├── 4. Notify listeners (Set, insertion order):
|
|
1609
|
+
│ listeners.forEach(fn => fn(state, previousState))
|
|
1610
|
+
│
|
|
1611
|
+
└── 5. Notify plugins:
|
|
1612
|
+
plugins.forEach(p => p.onStateChange?.(state, previousState))
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
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.
|
|
1616
|
+
|
|
1617
|
+
### Selector Re-render Optimization
|
|
1618
|
+
|
|
1619
|
+
```
|
|
1620
|
+
State update: { count: 1, theme: 'dark' } -> { count: 2, theme: 'dark' }
|
|
1621
|
+
|
|
1622
|
+
Component A: useBridgeState(bridge, s => s.count)
|
|
1623
|
+
-> getSnapshot() returns 2 (was 1)
|
|
1624
|
+
-> Object.is(1, 2) === false
|
|
1625
|
+
-> RE-RENDERS (correct: count changed)
|
|
1626
|
+
|
|
1627
|
+
Component B: useBridgeState(bridge, s => s.theme)
|
|
1628
|
+
-> getSnapshot() returns 'dark' (was 'dark')
|
|
1629
|
+
-> Object.is('dark', 'dark') === true
|
|
1630
|
+
-> SKIPS RE-RENDER (correct: theme unchanged)
|
|
1631
|
+
|
|
1632
|
+
Component C: useBridgeState(bridge, s => ({ count: s.count, theme: s.theme }))
|
|
1633
|
+
-> getSnapshot() returns NEW object { count: 2, theme: 'dark' }
|
|
1634
|
+
-> Object.is(prevObj, newObj) === false (different references!)
|
|
1635
|
+
-> RE-RENDERS (unnecessary: theme didn't change)
|
|
1636
|
+
|
|
1637
|
+
Component D: useBridgeState(bridge, s => ({ count: s.count, theme: s.theme }), { shallow: true })
|
|
1638
|
+
-> getSnapshot() returns { count: 2, theme: 'dark' }
|
|
1639
|
+
-> shallowEqual check: count changed (1 !== 2)
|
|
1640
|
+
-> Returns NEW reference
|
|
1641
|
+
-> RE-RENDERS (correct: count changed)
|
|
1642
|
+
|
|
1643
|
+
Component E (same selector, theme unchanged):
|
|
1644
|
+
-> getSnapshot() returns { count: 2, theme: 'dark' }
|
|
1645
|
+
-> shallowEqual check: all values same
|
|
1646
|
+
-> Returns PREVIOUS reference (same object)
|
|
1647
|
+
-> Object.is(prevRef, prevRef) === true
|
|
1648
|
+
-> SKIPS RE-RENDER (correct!)
|
|
1649
|
+
```
|
|
1650
|
+
|
|
1651
|
+
### Global Registry Internals
|
|
1652
|
+
|
|
1653
|
+
```typescript
|
|
1654
|
+
// The registry is a Map stored on globalThis with a Symbol.for key:
|
|
1655
|
+
|
|
1656
|
+
const REGISTRY_KEY = Symbol.for('shared-state-bridge.registry')
|
|
1657
|
+
|
|
1658
|
+
// globalThis[REGISTRY_KEY] = Map {
|
|
1659
|
+
// 'auth' => BridgeApi { ... },
|
|
1660
|
+
// 'app' => BridgeApi { ... },
|
|
1661
|
+
// 'ui' => BridgeApi { ... },
|
|
1662
|
+
// }
|
|
1663
|
+
|
|
1664
|
+
// Why Symbol.for() is critical:
|
|
1665
|
+
//
|
|
1666
|
+
// Module A: Symbol.for('shared-state-bridge.registry') → Symbol(123)
|
|
1667
|
+
// Module B: Symbol.for('shared-state-bridge.registry') → Symbol(123) (SAME!)
|
|
1668
|
+
// Duplicate package: Symbol.for('shared-state-bridge.registry') → Symbol(123) (SAME!)
|
|
1669
|
+
//
|
|
1670
|
+
// Plain string would also work, but Symbols avoid collisions with other
|
|
1671
|
+
// libraries that might use globalThis.
|
|
1672
|
+
```
|
|
1673
|
+
|
|
1674
|
+
### Persistence Flow
|
|
1675
|
+
|
|
1676
|
+
```
|
|
1677
|
+
createBridge({ plugins: [persist({ adapter, key, version: 2 })] })
|
|
1678
|
+
│
|
|
1679
|
+
├── Bridge initialized with initialState
|
|
1680
|
+
├── Bridge registered in global registry
|
|
1681
|
+
│
|
|
1682
|
+
└── persist.onInit(bridge):
|
|
1683
|
+
│
|
|
1684
|
+
├── Set up throttled writer function
|
|
1685
|
+
│
|
|
1686
|
+
└── Hydrate (async):
|
|
1687
|
+
│
|
|
1688
|
+
├── raw = await adapter.getItem(key)
|
|
1689
|
+
│
|
|
1690
|
+
├── if null → skip (first run, nothing persisted)
|
|
1691
|
+
│
|
|
1692
|
+
├── envelope = deserialize(raw) // { state, version }
|
|
1693
|
+
│
|
|
1694
|
+
├── if envelope.version === current version:
|
|
1695
|
+
│ └── bridge.setState(envelope.state) // merge persisted state
|
|
1696
|
+
│
|
|
1697
|
+
├── if version mismatch + migrate function:
|
|
1698
|
+
│ ├── migrated = migrate(envelope.state, envelope.version)
|
|
1699
|
+
│ └── bridge.setState(migrated)
|
|
1700
|
+
│
|
|
1701
|
+
└── if version mismatch + NO migrate:
|
|
1702
|
+
└── adapter.removeItem(key) // discard stale data
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
On every bridge.setState():
|
|
1706
|
+
│
|
|
1707
|
+
└── persist.onStateChange(state):
|
|
1708
|
+
│
|
|
1709
|
+
├── filtered = filterState(state) // apply pick/omit
|
|
1710
|
+
├── envelope = { state: filtered, version }
|
|
1711
|
+
├── serialized = serialize(envelope)
|
|
1712
|
+
└── adapter.setItem(key, serialized) // throttled
|
|
1713
|
+
```
|
|
1714
|
+
|
|
1715
|
+
### Sync Flow
|
|
1716
|
+
|
|
1717
|
+
```
|
|
1718
|
+
bridge.setState({ count: 5 }) // local update
|
|
1719
|
+
│
|
|
1720
|
+
├── plugins.forEach(p => p.onStateChange(state, prev))
|
|
1721
|
+
│ │
|
|
1722
|
+
│ └── sync.onStateChange(state)
|
|
1723
|
+
│ ├── isApplyingRemote? return (echo guard)
|
|
1724
|
+
│ └── sendState(state) // throttled
|
|
1725
|
+
│ ├── filtered = filterState(state) // apply pick/omit
|
|
1726
|
+
│ └── connection.send({ type: "state", channel, clientId, state: filtered })
|
|
1727
|
+
│ ├── ws.readyState === OPEN? ws.send(data)
|
|
1728
|
+
│ └── else: buffer.push(data) // flush on reconnect
|
|
1729
|
+
│
|
|
1730
|
+
▼ (incoming from server)
|
|
1731
|
+
ws.onmessage(data)
|
|
1732
|
+
│
|
|
1733
|
+
├── parse JSON
|
|
1734
|
+
├── message.clientId === ownClientId? skip (echo prevention)
|
|
1735
|
+
├── resolve? stateToApply = resolve(local, remote)
|
|
1736
|
+
│ else: stateToApply = remoteState (last-write-wins)
|
|
1737
|
+
├── isApplyingRemote = true // prevent re-broadcast
|
|
1738
|
+
├── bridge.setState(stateToApply)
|
|
1739
|
+
└── isApplyingRemote = false
|
|
1740
|
+
```
|
|
1741
|
+
|
|
1742
|
+
---
|
|
1743
|
+
|
|
1744
|
+
## Gotchas and Common Pitfalls
|
|
1745
|
+
|
|
1746
|
+
### 1. Bridge Name Collisions
|
|
1747
|
+
|
|
1748
|
+
```typescript
|
|
1749
|
+
createBridge({ name: 'app', initialState: {} })
|
|
1750
|
+
createBridge({ name: 'app', initialState: {} })
|
|
1751
|
+
// Error: A bridge named "app" already exists.
|
|
1752
|
+
```
|
|
1753
|
+
|
|
1754
|
+
**Fix:** Use unique names, or check with `hasBridge('app')` first, or `destroy()` the old bridge.
|
|
1755
|
+
|
|
1756
|
+
### 2. getBridge Before createBridge
|
|
1757
|
+
|
|
1758
|
+
```typescript
|
|
1759
|
+
const bridge = getBridge('auth')
|
|
1760
|
+
// Error: No bridge named "auth" found.
|
|
1761
|
+
```
|
|
1762
|
+
|
|
1763
|
+
**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.
|
|
1764
|
+
|
|
1765
|
+
### 3. Object Selectors Without Shallow
|
|
1766
|
+
|
|
1767
|
+
```tsx
|
|
1768
|
+
// This causes re-renders on EVERY state change
|
|
1769
|
+
const data = useBridgeState(bridge, s => ({
|
|
1770
|
+
a: s.a,
|
|
1771
|
+
b: s.b,
|
|
1772
|
+
}))
|
|
1773
|
+
```
|
|
1774
|
+
|
|
1775
|
+
**Fix:** Add `{ shallow: true }` or use separate primitive selectors.
|
|
1776
|
+
|
|
1777
|
+
### 4. Stale Closures in Updaters
|
|
1778
|
+
|
|
1779
|
+
```typescript
|
|
1780
|
+
// WRONG: count is captured once
|
|
1781
|
+
const count = bridge.getState().count
|
|
1782
|
+
bridge.setState({ count: count + 1 })
|
|
1783
|
+
bridge.setState({ count: count + 1 }) // Both set count to the same value!
|
|
1784
|
+
|
|
1785
|
+
// CORRECT: use updater function for sequential updates
|
|
1786
|
+
bridge.setState(s => ({ count: s.count + 1 }))
|
|
1787
|
+
bridge.setState(s => ({ count: s.count + 1 })) // Correctly increments twice
|
|
1788
|
+
```
|
|
1789
|
+
|
|
1790
|
+
### 5. Async Hydration Timing
|
|
1791
|
+
|
|
1792
|
+
Hydration from async adapters (AsyncStorage) happens asynchronously. State may briefly hold `initialState` before the persisted state loads:
|
|
1793
|
+
|
|
1794
|
+
```typescript
|
|
1795
|
+
const bridge = createBridge({
|
|
1796
|
+
name: 'app',
|
|
1797
|
+
initialState: { theme: 'light' },
|
|
1798
|
+
plugins: [persist({ adapter: asyncStorageAdapter(AsyncStorage), key: 'app' })],
|
|
1799
|
+
})
|
|
1800
|
+
|
|
1801
|
+
bridge.getState() // { theme: 'light' } (initial, not yet hydrated)
|
|
1802
|
+
|
|
1803
|
+
// After microtask:
|
|
1804
|
+
// bridge.getState() // { theme: 'dark' } (hydrated from storage)
|
|
1805
|
+
```
|
|
1806
|
+
|
|
1807
|
+
**Fix:** Design your UI to handle the initial state gracefully, or use a loading flag.
|
|
1808
|
+
|
|
1809
|
+
### 6. Mutating State Directly
|
|
1810
|
+
|
|
1811
|
+
```typescript
|
|
1812
|
+
// WRONG: mutation won't trigger listeners
|
|
1813
|
+
const state = bridge.getState()
|
|
1814
|
+
state.count = 5
|
|
1815
|
+
|
|
1816
|
+
// CORRECT: use setState
|
|
1817
|
+
bridge.setState({ count: 5 })
|
|
1818
|
+
```
|
|
1819
|
+
|
|
1820
|
+
### 7. Subscribing in useEffect Without Cleanup
|
|
1821
|
+
|
|
1822
|
+
```tsx
|
|
1823
|
+
// WRONG: leaks a listener on every render
|
|
1824
|
+
useEffect(() => {
|
|
1825
|
+
bridge.subscribe(listener)
|
|
1826
|
+
})
|
|
1827
|
+
|
|
1828
|
+
// CORRECT: return the unsubscribe function
|
|
1829
|
+
useEffect(() => {
|
|
1830
|
+
return bridge.subscribe(listener)
|
|
1831
|
+
}, [])
|
|
1832
|
+
|
|
1833
|
+
// BEST: use useBridgeState hook instead
|
|
1834
|
+
const value = useBridgeState(bridge, selector)
|
|
1835
|
+
```
|
|
1836
|
+
|
|
1837
|
+
---
|
|
1838
|
+
|
|
1839
|
+
## FAQ
|
|
1840
|
+
|
|
1841
|
+
**How does cross-package sharing work?**
|
|
1842
|
+
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](#architecture-deep-dive) for details.
|
|
1843
|
+
|
|
1844
|
+
**Can I use this without React?**
|
|
1845
|
+
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`).
|
|
1846
|
+
|
|
1847
|
+
**Does it work with SSR / Next.js?**
|
|
1848
|
+
Yes. `useBridgeState` uses `useSyncExternalStore` with a `getServerSnapshot` that returns `getInitialState()`, which is SSR-safe. See [Server-Side Rendering](#server-side-rendering-nextjs).
|
|
1849
|
+
|
|
1850
|
+
**How does this compare to Zustand?**
|
|
1851
|
+
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.
|
|
1852
|
+
|
|
1853
|
+
**What happens if two packages create a bridge with the same name?**
|
|
1854
|
+
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.
|
|
1855
|
+
|
|
1856
|
+
**Does it support React Native?**
|
|
1857
|
+
Yes. The core and React hooks work identically in React Native. For persistence, use `asyncStorageAdapter()` with `@react-native-async-storage/async-storage`.
|
|
1858
|
+
|
|
1859
|
+
**What's the bundle size?**
|
|
1860
|
+
- Core: ~1.2 KB gzipped
|
|
1861
|
+
- React hooks: ~1.1 KB gzipped
|
|
1862
|
+
- Persist plugin: ~1.0 KB gzipped
|
|
1863
|
+
- Sync plugin: ~1.8 KB gzipped
|
|
1864
|
+
- All four combined: ~5.1 KB gzipped
|
|
1865
|
+
|
|
1866
|
+
Each entry point is independently tree-shakeable. If you only use the core, React, persist, and sync code is never included.
|
|
1867
|
+
|
|
1868
|
+
**Can I have multiple BridgeProviders?**
|
|
1869
|
+
Yes. Each `BridgeProvider` provides its own bridge to its subtree. Components use the nearest provider. You can nest providers for different bridges.
|
|
1870
|
+
|
|
1871
|
+
**Is it concurrent-safe (React 18)?**
|
|
1872
|
+
Yes. `useBridgeState` uses `useSyncExternalStore`, which is React's official API for integrating external stores with concurrent features like `useTransition` and `Suspense`.
|
|
1873
|
+
|
|
1874
|
+
**Can I use this in a non-monorepo project?**
|
|
1875
|
+
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.
|
|
1876
|
+
|
|
1877
|
+
**Does the sync plugin actually sync between different apps (Next.js + React Native)?**
|
|
1878
|
+
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.
|
|
1879
|
+
|
|
1880
|
+
**Can I use both persist and sync together?**
|
|
1881
|
+
Yes. They are independent plugins that compose naturally:
|
|
1882
|
+
|
|
1883
|
+
```typescript
|
|
1884
|
+
createBridge({
|
|
1885
|
+
name: 'app',
|
|
1886
|
+
initialState: { theme: 'light', count: 0 },
|
|
1887
|
+
plugins: [
|
|
1888
|
+
persist({ adapter: localStorageAdapter, key: 'app' }),
|
|
1889
|
+
sync({ url: 'wss://server.com/sync', channel: 'room-1' }),
|
|
1890
|
+
],
|
|
1891
|
+
})
|
|
1892
|
+
```
|
|
1893
|
+
|
|
1894
|
+
State persists locally AND syncs in real-time with other connected apps.
|
|
1895
|
+
|
|
1896
|
+
---
|
|
1897
|
+
|
|
1898
|
+
## Contributing
|
|
1899
|
+
|
|
1900
|
+
Contributions are welcome! Here's how to get started:
|
|
1901
|
+
|
|
1902
|
+
```bash
|
|
1903
|
+
# Clone the repo
|
|
1904
|
+
git clone https://github.com/your-username/shared-state-bridge.git
|
|
1905
|
+
cd shared-state-bridge
|
|
1906
|
+
|
|
1907
|
+
# Install dependencies
|
|
1908
|
+
npm install
|
|
1909
|
+
|
|
1910
|
+
# Run tests
|
|
1911
|
+
npm test
|
|
1912
|
+
|
|
1913
|
+
# Run tests in watch mode
|
|
1914
|
+
npm run test:watch
|
|
1915
|
+
|
|
1916
|
+
# Type check
|
|
1917
|
+
npm run typecheck
|
|
1918
|
+
|
|
1919
|
+
# Build
|
|
1920
|
+
npm run build
|
|
1921
|
+
```
|
|
1922
|
+
|
|
1923
|
+
### Project Structure
|
|
1924
|
+
|
|
1925
|
+
```
|
|
1926
|
+
src/
|
|
1927
|
+
├── index.ts # Core entry point
|
|
1928
|
+
├── core/
|
|
1929
|
+
│ ├── types.ts # All TypeScript type definitions
|
|
1930
|
+
│ ├── utils.ts # shallowEqual, throttle, isFunction
|
|
1931
|
+
│ ├── registry.ts # Global bridge registry
|
|
1932
|
+
│ └── bridge.ts # createBridge + Bridge store logic
|
|
1933
|
+
├── react/
|
|
1934
|
+
│ ├── index.ts # React entry point
|
|
1935
|
+
│ ├── context.ts # React context
|
|
1936
|
+
│ ├── provider.tsx # BridgeProvider component
|
|
1937
|
+
│ └── hooks.ts # useBridgeState, useBridge
|
|
1938
|
+
├── persist/
|
|
1939
|
+
│ ├── index.ts # Persist entry point
|
|
1940
|
+
│ ├── plugin.ts # Persistence plugin
|
|
1941
|
+
│ └── adapters.ts # Storage adapters
|
|
1942
|
+
└── sync/
|
|
1943
|
+
├── index.ts # Sync entry point
|
|
1944
|
+
├── types.ts # SyncOptions, wire protocol types
|
|
1945
|
+
├── connection.ts # WebSocket manager (reconnect, buffer)
|
|
1946
|
+
└── plugin.ts # sync() plugin factory
|
|
1947
|
+
```
|
|
1948
|
+
|
|
1949
|
+
### Guidelines
|
|
1950
|
+
|
|
1951
|
+
- Keep the core dependency-free
|
|
1952
|
+
- Maintain 100% TypeScript strict mode compliance
|
|
1953
|
+
- Write tests for all new features
|
|
1954
|
+
- Keep bundle sizes minimal — every byte counts
|
|
1955
|
+
|
|
1956
|
+
---
|
|
1957
|
+
|
|
1958
|
+
## Support
|
|
1959
|
+
|
|
1960
|
+
If you find this package useful, consider buying me a coffee!
|
|
1961
|
+
|
|
1962
|
+
[](https://buymeacoffee.com/aemadeldin)
|
|
1963
|
+
|
|
1964
|
+
---
|
|
1965
|
+
|
|
1966
|
+
## License
|
|
1967
|
+
|
|
1968
|
+
MIT
|