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>
    );
}

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