@tovsa7/zerosync-react
v0.2.0
Published
React hooks for ZeroSync — end-to-end encrypted real-time collaboration
Maintainers
Readme
@tovsa7/zerosync-react
React hooks for ZeroSync — end-to-end encrypted real-time collaboration.
Declarative React bindings for the @tovsa7/zerosync-client SDK. Adds Google Docs-style collaboration to any React app — where your server never sees plaintext.
- ✅ Zero-knowledge server (AES-256-GCM via Web Crypto)
- ✅ Peer-to-peer via WebRTC DataChannel with encrypted relay fallback
- ✅ CRDT-based sync via Yjs
- ✅ Encrypted-at-rest persistence (v0.2.0+) — opt-in via
persistKeyprop, doc survives reload beforeRoom.joinresolves - ✅ No runtime dependencies — all Yjs/client/React are peer deps
- ✅ TypeScript strict mode, full type inference
- ✅ React 18+ (
useSyncExternalStorefor tear-free Yjs reactivity)
Install
npm install @tovsa7/zerosync-react @tovsa7/zerosync-client react yjsQuick start
import { useState, useEffect } from 'react'
import {
ZeroSyncProvider,
useYText,
useConnectionStatus,
} from '@tovsa7/zerosync-react'
import { deriveRoomKey } from '@tovsa7/zerosync-client'
function App() {
const [roomKey, setRoomKey] = useState<CryptoKey | null>(null)
useEffect(() => {
const secret = crypto.getRandomValues(new Uint8Array(32))
deriveRoomKey(secret, 'my-room').then(setRoomKey)
}, [])
if (!roomKey) return <p>Loading…</p>
return (
<ZeroSyncProvider
serverUrl="wss://sync.example.com/ws"
roomId="my-room"
roomKey={roomKey}
peerId={crypto.randomUUID()}
nonce={btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))))}
hmac=""
iceServers={[{ urls: 'stun:stun.l.google.com:19302' }]}
>
<Editor />
</ZeroSyncProvider>
)
}
function Editor() {
const status = useConnectionStatus()
const text = useYText('editor')
if (status !== 'connected') return <p>Status: {status}</p>
return (
<textarea
value={text?.toString() ?? ''}
onChange={(e) => {
// Naive sync — replace the whole Y.Text on every keystroke.
// For production, use a proper binding like y-prosemirror or y-codemirror.next.
text?.delete(0, text.length)
text?.insert(0, e.target.value)
}}
/>
)
}API
<ZeroSyncProvider>
Mounts a Room from the ZeroSync client SDK and exposes it through React context. Call Room.join(opts) on mount; call room.leave() on unmount.
<ZeroSyncProvider
serverUrl="wss://..."
roomId="..."
roomKey={cryptoKey}
peerId="..."
nonce="..."
hmac=""
iceServers={[...]}
onError={(err) => console.error(err)}
>
{children}
</ZeroSyncProvider>| Prop | Type | Notes |
|--------------|-----------------------------------------|---------------------------------------------------|
| serverUrl | string | WebSocket URL of the ZeroSync signaling server |
| roomId | string | Room identifier — opaque to the server |
| roomKey | CryptoKey | AES-256-GCM key — never transmitted to server |
| peerId | string | UUIDv4 for this peer |
| nonce | string | Base64 random bytes for HELLO replay protection |
| hmac | string | HMAC of HELLO message ("" while opt-in) |
| iceServers | RTCIceServer[] | WebRTC ICE servers (pass [] to disable STUN) |
| persistKey | CryptoKey optional | When set, the provider opens an EncryptedPersistence keyed by roomId + persistKey and threads it into Room.join. State is restored from IDB before the Room resolves; subsequent updates are saved on a 500 ms debounce. Lifecycle is fully managed — provider opens on mount, closes on unmount. See Persistence below. |
| onError | (e: unknown) => void optional | Called if Room.join rejects |
| children | ReactNode | |
Props are snapshotted at mount — changes after mount do not trigger rejoin. To switch rooms, unmount and remount (e.g. via a key prop).
useRoom(): Room | null
Returns the currently-joined Room instance, or null while Room.join is in flight or rejected. Use to call low-level SDK methods (getConnectionSummary, etc.).
const room = useRoom()
const summary = room?.getConnectionSummary()useConnectionStatus(): 'connecting' | 'connected' | 'reconnecting' | 'closed'
Reactive connection status.
'connecting'—<ZeroSyncProvider>mounted,Room.join()pending.'connected'— WebSocket is up (initial) or reconnected.'reconnecting'— WebSocket dropped, client retrying with backoff.'closed'— Provider unmounted, orRoom.joinrejected.
const status = useConnectionStatus()
if (status !== 'connected') return <Banner>Offline</Banner>useYText(name: string): Y.Text | null
Returns a Y.Text keyed by name. Component re-renders on every Y.Text mutation.
const text = useYText('editor')
text?.insert(0, 'hello')
console.log(text?.toString())useYMap<V>(name: string): Y.Map<V> | null
Returns a Y.Map. Component re-renders on every set / delete / nested update.
const cursors = useYMap<{ x: number; y: number }>('cursors')
cursors?.set('alice', { x: 10, y: 20 })useYArray<V>(name: string): Y.Array<V> | null
Returns a Y.Array. Component re-renders on every push / insert / delete.
const messages = useYArray<string>('chat')
messages?.push(['hello!'])
messages?.toArray().forEach((m) => console.log(m))usePresence<T>(): ReadonlyMap<string, T>
Returns a snapshot of remote peers' presence state (excluding the local peer). Re-renders on every awareness change.
interface UserPresence { name: string; color: string }
const peers = usePresence<UserPresence>()
for (const [peerId, { name, color }] of peers) {
console.log(peerId, name, color)
}useMyPresence<T>(): [T | null, (state: T) => void]
React-style [state, setState] tuple. setState updates local state AND broadcasts to peers via room.updatePresence.
interface MyPresence { name: string; color: string }
const [me, setMe] = useMyPresence<MyPresence>()
// Publish initial presence once connected
useEffect(() => {
if (status === 'connected') {
setMe({ name: 'Alice', color: '#f00' })
}
}, [status, setMe])Full-replace semantics: the value passed to setMe replaces the whole presence state. For partial updates, spread the previous value:
setMe({ ...me, cursor: { x, y } })Do not include peerId in the state — the SDK injects it internally as a routing field.
Persistence
Pages reload. Browsers crash. Without persistence, every collaborative session restarts from zero — even text typed seconds ago is gone if the tab refreshes.
Pass persistKey to <ZeroSyncProvider> to encrypt the Yjs document at rest
in IndexedDB. On reload, the doc is restored from disk before the
provider's connection status reaches 'connected' — no flash of empty
editor, no waiting on peers to sync.
import { useEffect, useState } from 'react'
import {
ZeroSyncProvider,
derivePersistKey,
useYText,
} from '@tovsa7/zerosync-react'
import { deriveRoomKey } from '@tovsa7/zerosync-client'
function App() {
const [keys, setKeys] = useState<{ roomKey: CryptoKey; persistKey: CryptoKey } | null>(null)
useEffect(() => {
const userSecret = crypto.getRandomValues(new Uint8Array(32))
Promise.all([
deriveRoomKey(userSecret, 'my-room'),
derivePersistKey(userSecret, 'my-room'),
]).then(([roomKey, persistKey]) => setKeys({ roomKey, persistKey }))
}, [])
if (!keys) return <p>Loading…</p>
return (
<ZeroSyncProvider
serverUrl="wss://sync.example.com/ws"
roomId="my-room"
roomKey={keys.roomKey}
persistKey={keys.persistKey}
peerId={crypto.randomUUID()}
nonce={btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))))}
hmac=""
iceServers={[{ urls: 'stun:stun.l.google.com:19302' }]}
>
<Editor />
</ZeroSyncProvider>
)
}What you get
- IDB row is ciphertext only — AES-256-GCM (12-byte IV + ciphertext+tag). Devtools, disk forensics, and any inspection of the IDB row see opaque bytes.
- Per-room database — name
zerosync-persistence-{roomId}. Wipe one room withindexedDB.deleteDatabase('zerosync-persistence-' + roomId)without touching others. - Debounced saves — 500 ms window coalesces rapid edits into a single write. Both local edits and remote merges (SYNC_RES) trigger save, so on-disk state tracks the merged document.
- Flushes on hide —
visibilitychange → hiddenandpagehideflush pending saves immediately, surviving tab close and BFCache eviction. - Tamper / wrong-key recovery — load failure is logged and swallowed; sync continues with peer SYNC_RES, and the next save overwrites the bad row.
Domain separation
persistKey and roomKey are independently derived from the same userSecret via HKDF-SHA-256 with different info strings:
deriveRoomKey(secret, roomId)—info: "zerosync-room:{roomId}"— wire encryptionderivePersistKey(secret, roomId)—info: "zerosync-persist:{roomId}"— at-rest encryption
A leak of the on-disk key cannot decrypt wire traffic, and vice versa. Same userSecret (32 bytes you stored once), distinct cryptographic domains.
Lifecycle
Fully managed by the provider:
- Mount: opens
EncryptedPersistence→ awaitsRoom.join(which restores stored state) → exposesRoomvia context. - Unmount: calls
room.leave()(flushes pending save) → thenpersistence.close(). Order matters: the final flush must land before the IDB connection closes.
If Room.join rejects (server unreachable), the provider closes the persistence to avoid leaking the IDB connection. If you unmount during the join, late-arriving Room and persistence are still cleaned up.
Lower-level access
If you need direct control over EncryptedPersistence (e.g. share across providers, custom lifecycle), open it yourself with the client SDK and pass it via persistence prop instead of persistKey:
import { EncryptedPersistence } from '@tovsa7/zerosync-client'
const persistence = await EncryptedPersistence.open({ roomId, key: persistKey })
// ... pass persistence directly to Room.join (vanilla SDK), or extend ZeroSyncProvider yourself.The provider itself only exposes the high-level persistKey prop — for the low-level path, fall back to @tovsa7/zerosync-client directly.
Examples
Collaborative text (plain textarea)
See Quick start above. For production, bind Y.Text to a proper editor:
- y-prosemirror — ProseMirror / Tiptap
- y-codemirror.next — CodeMirror 6
- y-quill — Quill
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
function Tiptap() {
const room = useRoom()
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: room?.getDoc() }),
],
}, [room])
return <EditorContent editor={editor} />
}Cursor presence
function WhiteboardWithCursors() {
const peers = usePresence<{ x: number; y: number; color: string }>()
const [, setMe] = useMyPresence<{ x: number; y: number; color: string }>()
return (
<div
onMouseMove={(e) => setMe({ x: e.clientX, y: e.clientY, color: '#f00' })}
style={{ position: 'relative', height: '100vh' }}
>
{Array.from(peers).map(([peerId, { x, y, color }]) => (
<div
key={peerId}
style={{
position: 'absolute',
left: x, top: y,
width: 10, height: 10,
background: color,
borderRadius: '50%',
transition: 'left 100ms, top 100ms',
}}
/>
))}
</div>
)
}Chat with useYArray
interface Message { author: string; text: string; ts: number }
function Chat() {
const messages = useYArray<Message>('chat')
const [draft, setDraft] = useState('')
if (!messages) return null
return (
<>
<ul>
{messages.toArray().map((m, i) => (
<li key={i}><b>{m.author}:</b> {m.text}</li>
))}
</ul>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<button onClick={() => {
messages.push([{ author: 'me', text: draft, ts: Date.now() }])
setDraft('')
}}>Send</button>
</>
)
}Troubleshooting
"Hook re-renders too often"
useYText / useYMap / useYArray re-render on every Yjs observe event — that's the point. If this causes performance issues in large lists, memoize derived data:
const snapshot = useMemo(() => arr?.toArray(), [arr?.length, arr]) // not perfectFor heavy lists, consider using Yjs's observeDeep manually outside the hook, or use a virtualizing list (react-window).
"Room.join keeps getting called on every render"
Provider snapshots props at mount; if you see rejoin logs, check whether the Provider is being unmounted and remounted (e.g. via conditional parent render, key-prop change, or HMR during development).
"useMyPresence doesn't publish on mount"
setMyPresence broadcasts only if a Room is available. On first mount the Room is null, so the initial state isn't published. Gate publishing on useConnectionStatus() === 'connected' (see example above).
"Two Yjs instances warning"
Yjs's constructor checks fail if your bundler includes two Yjs copies (one from this package's dev tree, one from your app). Dedupe in your bundler:
// vite.config.ts
export default defineConfig({
resolve: { dedupe: ['yjs'] },
})Bundle size
~3 KB ESM minified + gzipped. Zero runtime dependencies — React, Yjs, and @tovsa7/zerosync-client are all peer dependencies.
License
MIT — see LICENSE.
