TypeScript Client
Using @grove-js/crdt for browser-based CRDT sync with HTTP pull/push, SSE streaming, client-side merge, and React hooks.
The @grove-js/crdt TypeScript package provides a full CRDT client for browser and Node.js applications. It mirrors the Go wire format exactly, enabling cross-language sync between Go servers and JavaScript clients.
Installation
npm install @grove-js/crdtReact hooks require React 18+ as a peer dependency:
npm install reactCore Modules
The package exports from two entry points:
// Core: client, store, merge, clock, stream, presence
import { CRDTClient, CRDTStore, HybridClock, PresenceManager } from "@grove-js/crdt";
// Plugin interfaces (for custom implementations)
import type { Transport, StreamTransport, AuthProvider, StorageAdapter } from "@grove-js/crdt";
// React hooks
import {
CRDTProvider, useDocument, useCollection,
usePresence, useDocumentPresence,
} from "@grove-js/crdt/react";CRDTClient
The HTTP client for pull/push sync with the Go server:
import { CRDTClient } from "@grove-js/crdt";
const client = new CRDTClient({
baseURL: "https://api.example.com/sync",
nodeID: "browser-1",
tables: ["documents", "comments"],
headers: {
Authorization: "Bearer <token>",
},
});
// Pull changes from the server.
const { changes, latestHLC } = await client.pull();
// Push local changes to the server.
const pushResult = await client.push(pendingChanges);
// Create an SSE stream instance.
const stream = client.stream({ reconnectDelay: 5000 });Configuration
| Option | Type | Description |
|---|---|---|
baseURL | string | Sync server URL. Required when no custom transport is provided. |
nodeID | string | Unique client identifier |
tables | string[] | Tables to sync |
headers | Record<string, string> | Custom headers (used by built-in HttpTransport) |
fetch | typeof fetch | Custom fetch implementation (used by built-in HttpTransport) |
transport | Transport | Custom transport for pull/push operations. When provided, baseURL is not required. |
streamTransport | StreamTransport | Custom stream transport for real-time subscriptions |
auth | AuthProvider | Auth provider for dynamic header injection (e.g., JWT refresh) |
storage | StorageAdapter | Storage adapter for persistence (IndexedDB, localStorage, etc.) |
presence | PresenceConfig | Presence configuration. Set { heartbeatInterval: 10000 } to enable. |
Pluggable Architecture
The client supports pluggable transports, auth, and storage. Use the built-in defaults or provide your own implementations.
Transport Interface
interface Transport {
pull(req: PullRequest): Promise<PullResponse>;
push(req: PushRequest): Promise<PushResponse>;
updatePresence?(update: PresenceUpdate): Promise<void>; // Optional
getPresence?(topic: string): Promise<PresenceState[]>; // Optional
}The built-in HttpTransport sends JSON POST requests to /pull, /push, and /presence. For custom backends (WebSocket, gRPC, etc.), implement the Transport interface:
import type { Transport } from "@grove-js/crdt";
const wsTransport: Transport = {
async pull(req) { /* ... send via WebSocket ... */ },
async push(req) { /* ... send via WebSocket ... */ },
};
const client = new CRDTClient({
nodeID: "browser-1",
transport: wsTransport,
});StreamTransport Interface
Extends Transport with real-time subscriptions:
interface StreamTransport extends Transport {
subscribe(config: StreamConfig): StreamSubscription;
}The built-in HttpStreamTransport provides both HTTP pull/push and SSE streaming.
AuthProvider
For dynamic header injection (e.g., JWT token refresh):
interface AuthProvider {
getHeaders(): Record<string, string> | Promise<Record<string, string>>;
}
// Built-in static implementation
import { StaticAuthProvider } from "@grove-js/crdt";
const auth = new StaticAuthProvider({ Authorization: "Bearer <token>" });
// Custom JWT refresh
const jwtAuth: AuthProvider = {
async getHeaders() {
const token = await refreshTokenIfNeeded();
return { Authorization: `Bearer ${token}` };
},
};
const client = new CRDTClient({
baseURL: "/api/sync",
nodeID: "browser-1",
auth: jwtAuth,
});StorageAdapter
For persisting CRDT state to IndexedDB, localStorage, or other backends:
interface StorageAdapter {
loadState(): Promise<Map<string, Map<string, DocumentState>>>;
saveDocument(table: string, pk: string, doc: DocumentState): Promise<void>;
deleteDocument(table: string, pk: string): Promise<void>;
loadPendingChanges(): Promise<ChangeRecord[]>;
savePendingChanges(changes: ChangeRecord[]): Promise<void>;
}The built-in MemoryStorage is a no-op default. Implement StorageAdapter for offline persistence.
CRDTStore
In-memory state management with dirty tracking and subscriptions:
import { CRDTStore, HybridClock } from "@grove-js/crdt";
const clock = new HybridClock("browser-1");
const store = new CRDTStore("browser-1", clock);
// Apply remote changes from a pull.
store.applyChanges(changes);
// Read a document.
const doc = store.getDocument<Document>("documents", "doc-1");
// { id: "doc-1", title: "Hello", viewCount: 42, tags: ["go", "crdt"] }
// Read a collection.
const docs = store.getCollection<Document>("documents");
// Make local changes.
store.setField("documents", "doc-1", "title", "Updated Title");
store.incrementCounter("documents", "doc-1", "viewCount", 1);
store.addToSet("documents", "doc-1", "tags", "typescript");
// Get pending changes for push.
const pending = store.getPendingChanges();
await client.push(pending);
store.clearPendingChanges();Mutations
| Method | Description |
|---|---|
setField(table, pk, field, value) | Set a LWW field value |
incrementCounter(table, pk, field, delta) | Increment a counter |
decrementCounter(table, pk, field, delta) | Decrement a counter |
addToSet(table, pk, field, ...elements) | Add elements to a set |
removeFromSet(table, pk, field, ...elements) | Remove elements from a set |
deleteDocument(table, pk) | Tombstone a document |
Subscriptions
The store supports fine-grained subscriptions compatible with React's useSyncExternalStore:
// Subscribe to all changes.
const unsub = store.subscribe(() => {
console.log("State changed");
});
// Subscribe to a specific document.
const unsub = store.subscribeDocument("documents", "doc-1", () => {
console.log("Document changed");
});
// Subscribe to an entire table.
const unsub = store.subscribeCollection("documents", () => {
console.log("Collection changed");
});Client-Side Merge
The package includes pure functions matching the Go merge algorithms:
import { mergeLWW, mergeCounter, counterValue, mergeSet, setElements } from "@grove-js/crdt";
// LWW merge: higher HLC wins.
const winner = mergeLWW(localValue, remoteValue);
// Counter merge: per-node max.
const merged = mergeCounter(localCounter, remoteCounter);
const value = counterValue(merged); // sum(inc) - sum(dec)
// Set merge: add-wins union.
const mergedSet = mergeSet(localSet, remoteSet);
const elements = setElements(mergedSet); // active elementsHybridClock
The TypeScript HLC uses Date.now() * 1_000_000 to produce nanosecond timestamps compatible with Go:
import { HybridClock, hlcCompare, hlcAfter } from "@grove-js/crdt";
const clock = new HybridClock("browser-1");
// Generate a new HLC.
const hlc = clock.now();
// { ts: 1706000000000000000, c: 0, node: "browser-1" }
// Update from a remote HLC.
clock.update(remoteHLC);
// Compare two HLCs.
const cmp = hlcCompare(a, b); // -1, 0, or 1
const isAfter = hlcAfter(a, b); // booleanSSE Stream
The CRDTStream class uses fetch() + ReadableStream instead of EventSource to support custom headers:
import { CRDTStream } from "@grove-js/crdt";
const stream = new CRDTStream(
"https://api.example.com/sync",
{ tables: ["documents"], reconnectDelay: 5000 },
{ Authorization: "Bearer <token>" }
);
stream.on((event) => {
if (event.type === "change") {
store.applyChanges([event.data]);
} else if (event.type === "changes") {
store.applyChanges(event.data);
}
});
stream.connect();React Hooks
Import from the ./react sub-export:
import {
CRDTProvider,
useDocument,
useCollection,
useLWW,
useCounter,
useSet,
useSyncStatus,
useStream,
} from "@grove-js/crdt/react";CRDTProvider
Wraps your app with the CRDT context:
function App() {
return (
<CRDTProvider config={{
baseURL: "https://api.example.com/sync",
nodeID: "browser-1",
tables: ["documents"],
headers: { Authorization: "Bearer <token>" },
}}>
<DocumentList />
</CRDTProvider>
);
}The provider automatically:
- Creates a
HybridClock,CRDTClient, andCRDTStore - Pulls initial data on mount
- Connects the SSE stream for real-time updates
- Provides a
sync()function for manual sync
useDocument
Subscribe to a single document with update and delete operations:
function DocumentEditor({ id }: { id: string }) {
const { data, loading, update, remove } = useDocument<Document>("documents", id);
if (loading) return <div>Loading...</div>;
if (!data) return <div>Not found</div>;
return (
<div>
<input
value={data.title}
onChange={(e) => update("title", e.target.value)}
/>
<button onClick={remove}>Delete</button>
</div>
);
}useCollection
Subscribe to an entire table:
function DocumentList() {
const { items, loading, create } = useCollection<Document>("documents");
return (
<div>
{items.map((doc) => (
<DocumentEditor key={doc.id} id={doc.id} />
))}
<button onClick={() => create("new-id", { title: "Untitled" })}>
New Document
</button>
</div>
);
}useLWW
Subscribe to a single LWW field:
function TitleEditor({ docId }: { docId: string }) {
const { value, set } = useLWW<string>("documents", docId, "title");
return (
<input value={value ?? ""} onChange={(e) => set(e.target.value)} />
);
}useCounter
Subscribe to a counter field:
function ViewCounter({ docId }: { docId: string }) {
const { value, increment } = useCounter("documents", docId, "viewCount");
return (
<div>
<span>Views: {value}</span>
<button onClick={() => increment(1)}>+1</button>
</div>
);
}useSet
Subscribe to a set field:
function TagEditor({ docId }: { docId: string }) {
const { elements, add, remove, has } = useSet<string>("documents", docId, "tags");
return (
<div>
{elements.map((tag) => (
<span key={tag}>
{tag} <button onClick={() => remove(tag)}>x</button>
</span>
))}
<button onClick={() => add("new-tag")}>Add Tag</button>
</div>
);
}useSyncStatus
Monitor sync state:
function SyncIndicator() {
const { status, lastSyncTime, pendingCount, sync } = useSyncStatus();
return (
<div>
<span>Status: {status}</span>
<span>Pending: {pendingCount}</span>
<button onClick={sync} disabled={status === "syncing"}>
Sync Now
</button>
</div>
);
}useStream
Control the SSE stream connection:
function StreamControl() {
const { connected, disconnect, reconnect } = useStream();
return (
<div>
<span>{connected ? "Connected" : "Disconnected"}</span>
<button onClick={connected ? disconnect : reconnect}>
{connected ? "Disconnect" : "Reconnect"}
</button>
</div>
);
}usePresence
Subscribe to presence for any topic. See Presence & Awareness for the full guide.
import { usePresence } from "@grove-js/crdt/react";
interface UserPresence {
name: string;
cursor: { x: number; y: number } | null;
isTyping: boolean;
}
function CollaborativeEditor({ roomId }: { roomId: string }) {
const { others, updateMyPresence, leave } = usePresence<UserPresence>(roomId);
return (
<div>
{others.map((peer) => (
<div key={peer.node_id}>
{peer.data.name} {peer.data.isTyping ? "is typing..." : ""}
</div>
))}
<textarea
onKeyDown={() => updateMyPresence({ isTyping: true })}
onBlur={() => updateMyPresence({ isTyping: false, cursor: null })}
/>
</div>
);
}The hook automatically leaves the topic when the component unmounts.
useDocumentPresence
Convenience wrapper that constructs the topic as "table:pk":
import { useDocumentPresence } from "@grove-js/crdt/react";
function DocumentEditor({ docId }: { docId: string }) {
const { others, updateMyPresence } = useDocumentPresence<{
name: string;
color: string;
}>("documents", docId);
useEffect(() => {
updateMyPresence({ name: "Alice", color: "#3b82f6" });
}, []);
return (
<div>
<div className="active-users">
{others.map((p) => (
<span key={p.node_id} style={{ color: p.data.color }}>
{p.data.name}
</span>
))}
</div>
{/* ... editor content ... */}
</div>
);
}List and Document Operations
List Mutations
Operate on crdt:list fields with ordered insert, delete, and move:
// Insert an item into a list at a position.
store.insertIntoList("boards", "board-1", "tasks", "Buy groceries", { after: "task-2" });
// Insert at the beginning of the list.
store.insertIntoList("boards", "board-1", "tasks", "First task", { position: "start" });
// Delete an item from a list by its node ID.
store.deleteFromList("boards", "board-1", "tasks", "task-node-id");
// Move an item to a new position.
store.moveInList("boards", "board-1", "tasks", "task-node-id", { after: "task-5" });Document Mutations
Operate on crdt:document fields with per-path addressing:
// Set a field at a path (uses LWW merge by default).
store.setDocumentField("profiles", "user-1", "data", "address.city", "New York");
// Set a nested field.
store.setDocumentField("profiles", "user-1", "data", "preferences.theme", "dark");
// Delete a field at a path.
store.deleteDocumentField("profiles", "user-1", "data", "address.zip");
// Increment a counter at a path.
store.incrementDocumentCounter("profiles", "user-1", "data", "stats.loginCount", 1);
// Add to a set at a path.
store.addToDocumentSet("profiles", "user-1", "data", "roles", "editor");React Hooks for List and Document
import { useList, useNestedDocument } from "@grove-js/crdt/react";
function TaskList({ boardId }: { boardId: string }) {
const { items, insert, remove, move } = useList<string>("boards", boardId, "tasks");
return (
<ul>
{items.map((item, index) => (
<li key={item.id} draggable>
{item.value}
<button onClick={() => remove(item.id)}>x</button>
</li>
))}
<button onClick={() => insert("New task", { position: "end" })}>
Add Task
</button>
</ul>
);
}
function ProfileEditor({ userId }: { userId: string }) {
const { get, set, remove, paths } = useNestedDocument("profiles", userId, "data");
return (
<div>
<input
value={get("address.city") ?? ""}
onChange={(e) => set("address.city", e.target.value)}
placeholder="City"
/>
<input
value={get("address.zip") ?? ""}
onChange={(e) => set("address.zip", e.target.value)}
placeholder="ZIP"
/>
</div>
);
}Batch Writes
Group multiple mutations into a single atomic batch to reduce event overhead and ensure consistency:
// Chainable BatchWriter API.
store.batch("documents", "doc-1")
.setField("title", "Updated Title")
.incrementCounter("viewCount", 1)
.addToSet("tags", "featured")
.commit();
// Multiple field updates in one batch.
store.batch("profiles", "user-1")
.setField("name", "Alice Smith")
.setField("email", "alice@example.com")
.setField("status", "active")
.commit();The commit() call fires a single change notification and generates one set of pending changes for sync.
Undo/Redo
The store tracks mutation history for undo/redo support:
// Perform mutations.
store.setField("documents", "doc-1", "title", "Draft");
store.setField("documents", "doc-1", "title", "Final");
// Undo the last mutation.
store.undo();
// title is now "Draft"
// Redo the undone mutation.
store.redo();
// title is now "Final"
// Check capabilities.
console.log(store.canUndo); // boolean
console.log(store.canRedo); // booleanReact Hook
import { useUndo } from "@grove-js/crdt/react";
function UndoControls() {
const { undo, redo, canUndo, canRedo } = useUndo();
return (
<div>
<button onClick={undo} disabled={!canUndo}>Undo</button>
<button onClick={redo} disabled={!canRedo}>Redo</button>
</div>
);
}State Export/Import
Export and import CRDT state for backup, migration, or debugging:
// Export the entire store state.
const snapshot = store.exportState();
// Returns: { tables: { documents: { "doc-1": DocumentState, ... }, ... } }
// Export a single table.
const docsSnapshot = store.exportTable("documents");
// Import state into a store (merges with existing state).
store.importState(snapshot);The exported format is JSON-serializable and compatible across client versions.
IndexedDB Storage
Persist CRDT state to IndexedDB for offline-first applications:
import { IndexedDBStorage } from "@grove-js/crdt";
const storage = new IndexedDBStorage({
dbName: "my-app-crdt",
dbVersion: 1,
});
// Pass to CRDTStore.
const store = new CRDTStore("browser-1", clock, { storage });
// Or pass via CRDTProvider.
<CRDTProvider config={{
baseURL: "/api/sync",
nodeID: "browser-1",
tables: ["documents"],
storage: new IndexedDBStorage({ dbName: "my-app-crdt" }),
}}>
<App />
</CRDTProvider>The IndexedDBStorage adapter implements the StorageAdapter interface and automatically persists documents and pending changes across page reloads.
Room Client
The RoomClient provides an HTTP API for room management. See Room Management for the full guide.
import { RoomClient } from "@grove-js/crdt";
const rooms = new RoomClient({
baseURL: "/api/sync",
nodeID: "browser-1",
});
// Create a room.
await rooms.create("design-session", { maxParticipants: 10 });
// Join with participant data.
await rooms.join("design-session", {
name: "Alice",
color: "#3b82f6",
cursor: { type: "canvas", x: 0, y: 0 },
});
// List rooms.
const roomList = await rooms.list();
// Leave a room.
await rooms.leave("design-session");React Room Hooks
import { useRoom, useDocumentRoom, useRoomList } from "@grove-js/crdt/react";
function RoomBrowser() {
const { rooms, loading } = useRoomList();
return (
<ul>
{rooms.map((room) => (
<li key={room.id}>
{room.id} ({room.participantCount}/{room.maxParticipants})
</li>
))}
</ul>
);
}
function DocumentRoom({ docId }: { docId: string }) {
const { participants, join, leave, updateMyData } = useDocumentRoom("documents", docId);
useEffect(() => {
join({ name: "Alice", color: "#3b82f6" });
return () => { leave(); };
}, []);
return (
<div>
{participants.map((p) => (
<span key={p.nodeId} style={{ color: p.data.color }}>
{p.data.name}
</span>
))}
</div>
);
}Plugins
Extend the client-side store with custom logic via the StorePlugin interface:
import type { StorePlugin } from "@grove-js/crdt";
interface StorePlugin {
name: string;
onInit?(store: CRDTStore): void;
onBeforeMutation?(mutation: Mutation): Mutation | null;
onAfterMutation?(mutation: Mutation, prevState: DocumentState): void;
onBeforeSync?(changes: ChangeRecord[]): ChangeRecord[];
onAfterSync?(result: SyncResult): void;
onConflict?(field: string, local: FieldState, remote: FieldState, resolved: FieldState): void;
destroy?(): void;
}Registering Plugins
// Register a plugin on the store.
store.use(myPlugin);
// Or via CRDTProvider config.
<CRDTProvider config={{
baseURL: "/api/sync",
nodeID: "browser-1",
tables: ["documents"],
plugins: [validationPlugin, loggingPlugin],
}}>
<App />
</CRDTProvider>Example: Validation Plugin
const validationPlugin: StorePlugin = {
name: "validation",
onBeforeMutation(mutation) {
if (mutation.field === "title" && typeof mutation.value === "string") {
if (mutation.value.length > 200) {
console.warn("Title too long, truncating");
return { ...mutation, value: mutation.value.slice(0, 200) };
}
}
return mutation;
},
};Example: Conflict Logging Plugin
const conflictLogger: StorePlugin = {
name: "conflict-logger",
onConflict(field, local, remote, resolved) {
console.log(`Conflict on "${field}":`, {
localHLC: local.hlc,
remoteHLC: remote.hlc,
winner: resolved === remote ? "remote" : "local",
});
},
};React Hook
import { usePlugin } from "@grove-js/crdt/react";
function PluginStatus() {
const plugin = usePlugin("validation");
// Access plugin instance for runtime configuration
return <div>Plugin: {plugin?.name ?? "not loaded"}</div>;
}Conflict Notifications
Subscribe to merge conflicts for UI notifications or analytics:
import { useConflicts } from "@grove-js/crdt/react";
function ConflictBanner() {
const { conflicts, clearConflicts } = useConflicts();
if (conflicts.length === 0) return null;
return (
<div className="conflict-banner">
<p>{conflicts.length} conflict(s) resolved automatically</p>
<ul>
{conflicts.map((c, i) => (
<li key={i}>
{c.table}/{c.pk}: field "{c.field}" — {c.resolution} won
</li>
))}
</ul>
<button onClick={clearConflicts}>Dismiss</button>
</div>
);
}Rich Errors
The package exports typed error classes for precise error handling:
import {
CRDTErrorCode,
NetworkError,
ValidationError,
SyncError,
PluginError,
} from "@grove-js/crdt";
try {
await client.push(changes);
} catch (err) {
if (err instanceof NetworkError) {
console.error("Network issue:", err.message, err.statusCode);
} else if (err instanceof SyncError) {
console.error("Sync conflict:", err.code, err.details);
} else if (err instanceof ValidationError) {
console.error("Validation failed:", err.field, err.constraint);
} else if (err instanceof PluginError) {
console.error("Plugin error:", err.pluginName, err.message);
}
}Error Codes
| Code | Class | Description |
|---|---|---|
NETWORK_ERROR | NetworkError | HTTP request failed or timed out |
NETWORK_TIMEOUT | NetworkError | Request exceeded timeout |
SYNC_CONFLICT | SyncError | Unresolvable sync conflict |
SYNC_VERSION_MISMATCH | SyncError | Protocol version mismatch |
VALIDATION_FAILED | ValidationError | Field validation rule violated |
VALIDATION_SCHEMA | ValidationError | Schema mismatch |
PLUGIN_INIT_FAILED | PluginError | Plugin initialization error |
PLUGIN_HOOK_FAILED | PluginError | Plugin hook threw an error |
Full Example
A collaborative document editor with real-time CRDT sync:
import { CRDTProvider, useDocument, useCollection, useSyncStatus } from "@grove-js/crdt/react";
interface Document {
id: string;
title: string;
viewCount: number;
tags: string[];
}
function App() {
return (
<CRDTProvider config={{
baseURL: "/api/sync",
nodeID: `browser-${crypto.randomUUID()}`,
tables: ["documents"],
}}>
<SyncBar />
<DocumentList />
</CRDTProvider>
);
}
function SyncBar() {
const { status, pendingCount, sync } = useSyncStatus();
return (
<header>
<span>{status} | {pendingCount} pending</span>
<button onClick={sync}>Sync</button>
</header>
);
}
function DocumentList() {
const { items, create } = useCollection<Document>("documents");
return (
<div>
{items.map((doc) => (
<DocumentCard key={doc.id} id={doc.id} />
))}
<button onClick={() => create(crypto.randomUUID(), { title: "Untitled" })}>
New
</button>
</div>
);
}
function DocumentCard({ id }: { id: string }) {
const { data, update, remove } = useDocument<Document>("documents", id);
if (!data) return null;
return (
<div>
<input
value={data.title}
onChange={(e) => update("title", e.target.value)}
/>
<span>Views: {data.viewCount}</span>
<button onClick={remove}>Delete</button>
</div>
);
}