npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tovsa7/zerosync-react

v0.2.0

Published

React hooks for ZeroSync — end-to-end encrypted real-time collaboration

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 persistKey prop, doc survives reload before Room.join resolves
  • ✅ No runtime dependencies — all Yjs/client/React are peer deps
  • ✅ TypeScript strict mode, full type inference
  • ✅ React 18+ (useSyncExternalStore for tear-free Yjs reactivity)

Install

npm install @tovsa7/zerosync-react @tovsa7/zerosync-client react yjs

Quick 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, or Room.join rejected.
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 with indexedDB.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 hidevisibilitychange → hidden and pagehide flush 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 encryption
  • derivePersistKey(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 → awaits Room.join (which restores stored state) → exposes Room via context.
  • Unmount: calls room.leave() (flushes pending save) → then persistence.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:

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 perfect

For 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.

Links