Grove

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/crdt

React hooks require React 18+ as a peer dependency:

npm install react

Core 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

OptionTypeDescription
baseURLstringSync server URL. Required when no custom transport is provided.
nodeIDstringUnique client identifier
tablesstring[]Tables to sync
headersRecord<string, string>Custom headers (used by built-in HttpTransport)
fetchtypeof fetchCustom fetch implementation (used by built-in HttpTransport)
transportTransportCustom transport for pull/push operations. When provided, baseURL is not required.
streamTransportStreamTransportCustom stream transport for real-time subscriptions
authAuthProviderAuth provider for dynamic header injection (e.g., JWT refresh)
storageStorageAdapterStorage adapter for persistence (IndexedDB, localStorage, etc.)
presencePresenceConfigPresence 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

MethodDescription
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 elements

HybridClock

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); // boolean

SSE 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, and CRDTStore
  • 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); // boolean

React 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

CodeClassDescription
NETWORK_ERRORNetworkErrorHTTP request failed or timed out
NETWORK_TIMEOUTNetworkErrorRequest exceeded timeout
SYNC_CONFLICTSyncErrorUnresolvable sync conflict
SYNC_VERSION_MISMATCHSyncErrorProtocol version mismatch
VALIDATION_FAILEDValidationErrorField validation rule violated
VALIDATION_SCHEMAValidationErrorSchema mismatch
PLUGIN_INIT_FAILEDPluginErrorPlugin initialization error
PLUGIN_HOOK_FAILEDPluginErrorPlugin 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>
    );
}

On this page