-
Notifications
You must be signed in to change notification settings - Fork 2
Collaboration
useYjsCollaboration() is the intended integration path for realtime collaboration.
The collaboration controller owns the live shared document state, awareness state, and transport lifecycle. The editor should be bound to that controller, not to a separate app-managed JSON document state.
Use the editorBindings returned by useYjsCollaboration() directly:
import {
NativeRichTextEditor,
useYjsCollaboration,
} from '@apollohg/react-native-prose-editor';
export function CollaborativeEditor() {
const collaboration = useYjsCollaboration({
documentId: 'case-123',
createWebSocket: () => new WebSocket('wss://example.com/yjs/case-123'),
localAwareness: {
userId: 'u1',
name: 'Jayden',
color: '#0A84FF',
},
});
return (
<NativeRichTextEditor
valueJSON={collaboration.editorBindings.valueJSON}
onContentChangeJSON={collaboration.editorBindings.onContentChangeJSON}
onSelectionChange={collaboration.editorBindings.onSelectionChange}
onFocus={collaboration.editorBindings.onFocus}
onBlur={collaboration.editorBindings.onBlur}
remoteSelections={collaboration.editorBindings.remoteSelections}
/>
);
}interface YjsCollaborationOptions {
documentId: string;
createWebSocket: () => WebSocket;
connect?: boolean;
retryIntervalMs?: YjsRetryInterval | false;
fragmentName?: string;
schema?: SchemaDefinition;
initialDocumentJson?: DocumentJSON;
initialEncodedState?: EncodedCollaborationStateInput;
localAwareness: LocalAwarenessUser;
onPeersChange?: (peers: CollaborationPeer[]) => void;
onStateChange?: (state: YjsCollaborationState) => void;
onError?: (error: Error) => void;
}| Option | Type | Default | Description |
|---|---|---|---|
documentId |
string |
— | Document identifier used to scope the collaboration session. |
createWebSocket |
() => WebSocket |
— | Factory that returns a new WebSocket connection to the Yjs sync server. Called on initial connect and on every reconnect. |
connect |
boolean |
true |
Whether to connect automatically when the controller is created. Set to false to defer connection until connect() is called. |
retryIntervalMs |
YjsRetryInterval | false |
exponential backoff | Retry interval configuration. Pass false to disable automatic retry entirely. See Reconnect Behavior. |
fragmentName |
string |
'default' |
Name of the Yjs XML fragment within the shared document. Only change this if your server uses a non-default fragment name. |
schema |
SchemaDefinition |
preset default | Optional schema passed to the native collaboration bridge. Also used to choose the local empty-document bootstrap block when there is no encoded state yet. |
initialDocumentJson |
DocumentJSON |
— | Local fallback document used when there is no encoded state yet. Not a durable collaboration restore format. See Initial State Rules. |
initialEncodedState |
EncodedCollaborationStateInput |
— | Previously persisted CRDT state to restore from. Accepts Uint8Array, readonly number[], or a base64 string. |
localAwareness |
LocalAwarenessUser |
— | Local user identity and appearance. See LocalAwarenessUser. |
onPeersChange |
(peers: CollaborationPeer[]) => void |
— | Called when the set of connected peers changes. |
onStateChange |
(state: YjsCollaborationState) => void |
— | Called when the collaboration state changes (connection status, document content, errors). |
onError |
(error: Error) => void |
— | Called when a collaboration error occurs (transport failures, sync errors). |
interface LocalAwarenessUser {
userId: string;
name: string;
color: string;
avatarUrl?: string;
extra?: Record<string, unknown>;
}| Field | Type | Description |
|---|---|---|
userId |
string |
Unique identifier for the local user. |
name |
string |
Display name shown alongside the remote caret on other clients. |
color |
string |
Color used for this user's remote selection highlight on other clients. |
avatarUrl |
string | undefined |
URL of the user's avatar image, broadcast to other peers via awareness. |
extra |
Record<string, unknown> | undefined |
Arbitrary metadata broadcast to other peers via awareness. Use for roles, status, or any app-specific data. |
The full awareness state broadcast to other peers, combining user identity with editor state.
interface LocalAwarenessState {
user: LocalAwarenessUser;
selection?: {
anchor: number;
head: number;
};
focused?: boolean;
}| Field | Type | Description |
|---|---|---|
user |
LocalAwarenessUser |
User identity and appearance. |
selection |
{ anchor: number; head: number } | undefined |
Current editor selection range. |
focused |
boolean | undefined |
Whether the user's editor is currently focused. |
type YjsTransportStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
interface YjsCollaborationState {
documentId: string;
status: YjsTransportStatus;
isConnected: boolean;
documentJson: DocumentJSON;
lastError?: Error;
}| Field | Type | Description |
|---|---|---|
documentId |
string |
The document identifier for this session. |
status |
YjsTransportStatus |
Current transport status. |
isConnected |
boolean |
Whether the transport is currently connected. |
documentJson |
DocumentJSON |
Current shared document content as JSON. This is the actual CRDT-backed state and may legitimately be an empty doc. |
lastError |
Error | undefined |
Most recent error, if any. |
interface CollaborationPeer {
clientId: number;
isLocal: boolean;
state: Record<string, unknown> | null;
}| Field | Type | Description |
|---|---|---|
clientId |
number |
Unique Yjs client identifier for this peer. |
isLocal |
boolean |
Whether this peer is the local user. |
state |
Record<string, unknown> | null |
Raw awareness state broadcast by this peer, or null if not yet received. |
type EncodedCollaborationStateInput = Uint8Array | readonly number[] | string;Accepted input formats for encoded CRDT state. When a string is provided it is treated as base64-encoded.
function useYjsCollaboration(options: YjsCollaborationOptions): UseYjsCollaborationResult;interface UseYjsCollaborationResult {
state: YjsCollaborationState;
peers: CollaborationPeer[];
isConnected: boolean;
connect(): void;
disconnect(): void;
reconnect(): void;
getEncodedState(): Uint8Array;
getEncodedStateBase64(): string;
applyEncodedState(encodedState: EncodedCollaborationStateInput): void;
replaceEncodedState(encodedState: EncodedCollaborationStateInput): void;
updateLocalAwareness(partial: Partial<LocalAwarenessState>): void;
editorBindings: {
valueJSON: DocumentJSON;
remoteSelections: RemoteSelectionDecoration[];
onContentChangeJSON: (doc: DocumentJSON) => void;
onSelectionChange: (selection: Selection) => void;
onFocus: () => void;
onBlur: () => void;
};
}| Field | Type | Description |
|---|---|---|
state |
YjsCollaborationState |
Current collaboration state including connection status and document content. |
peers |
CollaborationPeer[] |
All currently connected peers (including the local user). |
isConnected |
boolean |
Shorthand for state.isConnected. |
connect() |
() => void |
Open the WebSocket connection. Only needed if connect: false was passed. |
disconnect() |
() => void |
Close the WebSocket connection, clear the local awareness session from peers, and stop retrying. |
reconnect() |
() => void |
Disconnect and immediately reconnect. |
getEncodedState() |
() => Uint8Array |
Get the current encoded CRDT state as bytes. |
getEncodedStateBase64() |
() => string |
Get the current encoded CRDT state as a base64 string. |
applyEncodedState(...) |
(state) => void |
Merge an encoded CRDT state into the current document. |
replaceEncodedState(...) |
(state) => void |
Replace the entire CRDT state. Use with caution — this overwrites the local document. |
updateLocalAwareness(...) |
(partial) => void |
Update the local awareness state (selection, focus, user info) broadcast to other peers. |
editorBindings |
object | Props to spread onto NativeRichTextEditor. See Correct Wiring. |
The hook does not expose a destroy() method. Lifecycle is managed automatically by the hook's useEffect cleanup when the component unmounts. Use createYjsCollaborationController() if you need manual lifecycle control.
The imperative (non-hook) API for environments where React hooks are not available or when you need manual lifecycle control.
function createYjsCollaborationController(
options: YjsCollaborationOptions
): YjsCollaborationController;interface YjsCollaborationController {
readonly state: YjsCollaborationState;
readonly peers: CollaborationPeer[];
connect(): void;
disconnect(): void;
reconnect(): void;
destroy(): void;
getEncodedState(): Uint8Array;
getEncodedStateBase64(): string;
applyEncodedState(encodedState: EncodedCollaborationStateInput): void;
replaceEncodedState(encodedState: EncodedCollaborationStateInput): void;
updateLocalAwareness(partial: Partial<LocalAwarenessState>): void;
handleLocalDocumentChange(doc: DocumentJSON): void;
handleSelectionChange(selection: Selection): void;
handleFocusChange(focused: boolean): void;
}| Method / Property | Type | Description |
|---|---|---|
state |
YjsCollaborationState |
Current collaboration state (read-only). |
peers |
CollaborationPeer[] |
Currently connected peers (read-only). |
connect() |
() => void |
Open the WebSocket connection. |
disconnect() |
() => void |
Close the WebSocket connection, clear the local awareness session from peers, and stop retrying. |
reconnect() |
() => void |
Disconnect and immediately reconnect. |
destroy() |
() => void |
Disconnect, clear the local awareness session from peers, and release all resources. The controller cannot be reused after this. |
getEncodedState() |
() => Uint8Array |
Get the current encoded CRDT state as bytes. |
getEncodedStateBase64() |
() => string |
Get the current encoded CRDT state as a base64 string. |
applyEncodedState(...) |
(state) => void |
Merge an encoded CRDT state into the current document. |
replaceEncodedState(...) |
(state) => void |
Replace the entire CRDT state. |
updateLocalAwareness(...) |
(partial) => void |
Update the local awareness state broadcast to other peers. |
handleLocalDocumentChange(doc) |
(doc) => void |
Feed a local document change into the collaboration session. Use this to wire the controller to onContentChangeJSON. |
handleSelectionChange(selection) |
(selection) => void |
Feed a local selection change into the collaboration session. Use this to wire the controller to onSelectionChange. |
handleFocusChange(focused) |
(focused) => void |
Feed a local focus change into the collaboration session. Use this to wire the controller to onFocus/onBlur. |
function encodeCollaborationStateBase64(encodedState: EncodedCollaborationStateInput): string;
function decodeCollaborationStateBase64(base64: string): Uint8Array;| Function | Description |
|---|---|
encodeCollaborationStateBase64(state) |
Convert an encoded CRDT state (bytes or number array) to a base64 string for storage or transport. |
decodeCollaborationStateBase64(base64) |
Decode a base64 string back to a Uint8Array for use with applyEncodedState or replaceEncodedState. |
interface YjsRetryContext {
attempt: number;
documentId: string;
lastError?: Error;
}
type YjsRetryInterval = number | ((context: YjsRetryContext) => number | null | false);When retryIntervalMs is a number, that fixed interval is used between every retry. When it is a function, it receives the retry context and should return the delay in milliseconds, or null/false to stop retrying.
The collaboration transport retries automatically by default with exponential backoff after unexpected disconnects.
- attempt 1:
500ms - attempt 2:
1000ms - attempt 3:
2000ms - then doubling up to a
30000mscap
You can override that with retryIntervalMs:
const collaboration = useYjsCollaboration({
documentId: 'case-123',
createWebSocket: () => new WebSocket('wss://example.com/yjs/case-123'),
retryIntervalMs: ({ attempt, lastError }) => {
if (lastError?.message.includes('auth')) {
return false;
}
return Math.min(1000 * 2 ** (attempt - 1), 15000);
},
localAwareness: {
userId: 'u1',
name: 'Jayden',
color: '#0A84FF',
},
});Use retryIntervalMs: false to disable automatic retry entirely.
In collaboration mode:
- the collaboration session is the document source of truth
-
valueJSONshould come fromuseYjsCollaboration().editorBindings.valueJSON -
onContentChangeJSONshould go back touseYjsCollaboration().editorBindings.onContentChangeJSON
Do not keep a second app-owned JSON document state and feed that back into valueJSON on every render. That creates competing sources of truth and can cause selection churn, stale restores, or remote updates being replayed incorrectly.
For durable offline recovery or delayed sync, persist the encoded CRDT state, not just the visible JSON document.
Available controller methods:
getEncodedState()getEncodedStateBase64()applyEncodedState(...)replaceEncodedState(...)
Use encoded state when you need to restore the actual Yjs/CRDT state later. JSON is only a content snapshot.
For storage and transport, the encodeCollaborationStateBase64 and decodeCollaborationStateBase64 utility functions convert between binary state and base64 strings.
Prefer these inputs in this order:
-
initialEncodedStatewhen you have previously persisted collaboration state - backend sync over WebSocket when the room loads
-
initialDocumentJsononly as a local fallback when there is no encoded state yet
initialDocumentJson is not a durable collaboration restore format.
If neither initialEncodedState nor initialDocumentJson is provided, the controller may synthesize a schema-aware empty text block locally so the native editor has an editable bootstrap document.
That bootstrap fallback is not the shared collaboration state. After the session starts, state.documentJson and editorBindings.valueJSON reflect the actual CRDT document, including a legitimately empty { type: 'doc', content: [] } when collaborators delete all content or a backend restores an empty state.
Remote cursors should be passed through remoteSelections from editorBindings. Do not map remote awareness peers onto the local editor selection yourself.
The package renders remote selections as native overlays. They are not meant to become the local user selection or move the active caret.
When the hook unmounts, it disconnects automatically and clears the local awareness session before releasing the native controller.
If you use the imperative controller directly, call disconnect() when the editor should leave the room, or destroy() when the controller will never be reused. Both paths clear the local awareness session so remote clients can remove the departing cursor instead of showing duplicate stale peers after a remount.
The collaboration transport is intended for standard Yjs sync + awareness peers such as:
- web clients using
y-websocket-style providers - backends that speak the standard Yjs sync and awareness protocol
The package adapts between:
- native editor numeric document selections
- standard Yjs awareness cursor payloads
This is the incorrect pattern:
const [doc, setDoc] = useState(...)
const collaboration = useYjsCollaboration(...)
<NativeRichTextEditor
valueJSON={doc}
onContentChangeJSON={setDoc}
remoteSelections={collaboration.editorBindings.remoteSelections}
/>That makes your app state and the collaboration session compete.
Use the collaboration bindings as the editor bindings instead.
Copyright © 2026 Apollo Health Group Pty. Ltd.