Grove

CRDT: React App

Build a collaborative React application with real-time CRDT sync using @grove-js/crdt and Grove.

This guide builds a complete collaborative document editor using the @grove-js/crdt TypeScript client with React hooks, connected to a Grove Go backend.

Prerequisites

  • A Grove Go server with CRDT sync endpoints (see Getting Started)
  • Node.js 18+ and React 18+

Project Setup

Go Server

Set up the backend with Forge and CRDT sync:

package main

import (
    "context"
    "log"
    "time"

    "github.com/xraph/forge"
    "github.com/xraph/grove/crdt"
    "github.com/xraph/grove/hook"
    "github.com/xraph/grove/drivers/pgdriver"
    groveext "github.com/xraph/grove/extension"
)

type Document struct {
    grove.BaseModel `grove:"table:documents,alias:d"`
    ID        string   `grove:"id,pk"`
    Title     string   `grove:"title,crdt:lww"`
    Body      string   `grove:"body,crdt:lww"`
    ViewCount int64    `grove:"view_count,crdt:counter"`
    Tags      []string `grove:"tags,type:jsonb,crdt:set"`
}

func main() {
    ctx := context.Background()

    pgdb := pgdriver.New()
    pgdb.Open(ctx, "postgres://user:pass@localhost:5432/mydb")

    plugin := crdt.New(crdt.WithNodeID("server-1"))

    app := forge.New()
    app.Use(groveext.New(
        groveext.WithDriver(pgdb),
        groveext.WithCRDT(plugin, hook.Scope{Tables: []string{"documents"}}),
        groveext.WithSyncController(
            crdt.WithStreamPollInterval(500 * time.Millisecond),
            crdt.WithStreamKeepAlive(15 * time.Second),
        ),
        groveext.WithMigrations(crdt.Migrations),
        groveext.WithBasePath("/api/sync"),
    ))

    app.Start(ctx) // Sync at /api/sync/pull, /api/sync/push, /api/sync/stream
}

React Client

Install the TypeScript CRDT package:

npm install @grove-js/crdt react

Application Root

Wrap your app with CRDTProvider:

// src/App.tsx
import { CRDTProvider } from "@grove-js/crdt/react";
import { DocumentList } from "./DocumentList";
import { SyncBar } from "./SyncBar";

export function App() {
    return (
        <CRDTProvider config={{
            baseURL: "/api/sync",
            nodeID: `browser-${crypto.randomUUID().slice(0, 8)}`,
            tables: ["documents"],
        }}>
            <div className="app">
                <SyncBar />
                <DocumentList />
            </div>
        </CRDTProvider>
    );
}

The provider automatically:

  • Creates a HybridClock, CRDTClient, and CRDTStore
  • Pulls initial data on mount
  • Connects to the SSE stream for real-time updates

Sync Status Bar

Show sync state and provide a manual sync button:

// src/SyncBar.tsx
import { useSyncStatus, useStream } from "@grove-js/crdt/react";

export function SyncBar() {
    const { status, pendingCount, lastSyncTime, sync } = useSyncStatus();
    const { connected } = useStream();

    return (
        <header className="sync-bar">
            <div className="sync-info">
                <span className={`status ${connected ? "online" : "offline"}`}>
                    {connected ? "Live" : "Offline"}
                </span>
                {pendingCount > 0 && (
                    <span className="pending">{pendingCount} pending</span>
                )}
                {lastSyncTime && (
                    <span className="last-sync">
                        Last sync: {new Date(lastSyncTime).toLocaleTimeString()}
                    </span>
                )}
            </div>
            <button
                onClick={sync}
                disabled={status === "syncing"}
                className="sync-button"
            >
                {status === "syncing" ? "Syncing..." : "Sync Now"}
            </button>
        </header>
    );
}

Document List

Display all documents with create functionality:

// src/DocumentList.tsx
import { useCollection } from "@grove-js/crdt/react";
import { DocumentCard } from "./DocumentCard";

interface Document {
    id: string;
    title: string;
    body: string;
    viewCount: number;
    tags: string[];
}

export function DocumentList() {
    const { items, loading, create } = useCollection<Document>("documents");

    const handleCreate = () => {
        const id = crypto.randomUUID();
        create(id, {
            title: "Untitled Document",
            body: "",
        });
    };

    if (loading) {
        return <div className="loading">Loading documents...</div>;
    }

    return (
        <div className="document-list">
            <div className="list-header">
                <h2>Documents ({items.length})</h2>
                <button onClick={handleCreate} className="create-button">
                    New Document
                </button>
            </div>
            <div className="list-grid">
                {items.map((doc) => (
                    <DocumentCard key={doc.id} id={doc.id} />
                ))}
            </div>
            {items.length === 0 && (
                <p className="empty">No documents yet. Create one to get started.</p>
            )}
        </div>
    );
}

Document Card

Edit a single document with LWW fields:

// src/DocumentCard.tsx
import { useState } from "react";
import { useDocument } from "@grove-js/crdt/react";
import { TagEditor } from "./TagEditor";
import { ViewCounter } from "./ViewCounter";

interface Document {
    id: string;
    title: string;
    body: string;
    viewCount: number;
    tags: string[];
}

export function DocumentCard({ id }: { id: string }) {
    const { data, loading, update, remove } = useDocument<Document>("documents", id);
    const [expanded, setExpanded] = useState(false);

    if (loading) return <div className="card loading">Loading...</div>;
    if (!data) return null;

    return (
        <div className="card">
            <div className="card-header">
                <input
                    className="title-input"
                    value={data.title}
                    onChange={(e) => update("title", e.target.value)}
                    placeholder="Document title..."
                />
                <button onClick={() => setExpanded(!expanded)} className="toggle">
                    {expanded ? "Collapse" : "Expand"}
                </button>
            </div>

            {expanded && (
                <div className="card-body">
                    <textarea
                        className="body-editor"
                        value={data.body}
                        onChange={(e) => update("body", e.target.value)}
                        placeholder="Write something..."
                        rows={6}
                    />
                </div>
            )}

            <div className="card-footer">
                <ViewCounter docId={id} />
                <TagEditor docId={id} />
                <button onClick={remove} className="delete-button">
                    Delete
                </button>
            </div>
        </div>
    );
}

Counter: View Tracker

Use the useCounter hook for the view count:

// src/ViewCounter.tsx
import { useCounter } from "@grove-js/crdt/react";

export function ViewCounter({ docId }: { docId: string }) {
    const { value, increment } = useCounter("documents", docId, "viewCount");

    return (
        <div className="view-counter">
            <span className="count">{value} views</span>
            <button onClick={() => increment(1)} className="view-button">
                +1
            </button>
        </div>
    );
}

Set: Tag Editor

Use the useSet hook for tags:

// src/TagEditor.tsx
import { useState } from "react";
import { useSet } from "@grove-js/crdt/react";

export function TagEditor({ docId }: { docId: string }) {
    const { elements, add, remove, has } = useSet<string>("documents", docId, "tags");
    const [input, setInput] = useState("");

    const handleAdd = () => {
        const tag = input.trim();
        if (tag && !has(tag)) {
            add(tag);
            setInput("");
        }
    };

    const handleKeyDown = (e: React.KeyboardEvent) => {
        if (e.key === "Enter") {
            e.preventDefault();
            handleAdd();
        }
    };

    return (
        <div className="tag-editor">
            <div className="tag-list">
                {elements.map((tag) => (
                    <span key={tag} className="tag">
                        {tag}
                        <button onClick={() => remove(tag)} className="tag-remove">
                            x
                        </button>
                    </span>
                ))}
            </div>
            <div className="tag-input-group">
                <input
                    value={input}
                    onChange={(e) => setInput(e.target.value)}
                    onKeyDown={handleKeyDown}
                    placeholder="Add tag..."
                    className="tag-input"
                />
                <button onClick={handleAdd} className="tag-add">
                    Add
                </button>
            </div>
        </div>
    );
}

LWW Field: Inline Editor

Use useLWW for fine-grained field subscriptions:

// src/InlineEditor.tsx
import { useLWW } from "@grove-js/crdt/react";

export function InlineEditor({
    table,
    pk,
    field,
    placeholder,
}: {
    table: string;
    pk: string;
    field: string;
    placeholder?: string;
}) {
    const { value, set } = useLWW<string>(table, pk, field);

    return (
        <input
            value={value ?? ""}
            onChange={(e) => set(e.target.value)}
            placeholder={placeholder}
            className="inline-editor"
        />
    );
}

How Sync Works End-to-End

Here is what happens when User A edits a title and User B sees the update:

  1. User A types in the title input
  2. update("title", "New Title") calls store.setField(), creating a change with a new HLC
  3. The store notifies subscribers, React re-renders the component
  4. The provider periodically calls client.push(store.getPendingChanges())
  5. The Go server receives the push, merges via SyncController.HandlePush()
  6. The SSE stream detects the new change and sends an event
  7. User B's CRDTStream receives the event, calls store.applyChanges()
  8. useSyncExternalStore triggers React re-render on User B's screen
  9. User B sees the updated title

The entire flow happens in sub-second latency with SSE streaming. If the SSE connection drops, the periodic pull/push sync catches up on the next interval.

Handling Offline

The CRDT store works entirely in-memory on the client. When the network is unavailable:

  • Local mutations still work (stored in the CRDTStore)
  • Pending changes accumulate in getPendingChanges()
  • When connectivity returns, the SSE stream reconnects automatically
  • The next sync pushes all pending changes and pulls missed remote changes
  • The merge engine resolves any conflicts deterministically
function OfflineIndicator() {
    const { connected } = useStream();
    const { pendingCount } = useSyncStatus();

    if (connected) return null;

    return (
        <div className="offline-banner">
            You are offline. {pendingCount} changes will sync when reconnected.
        </div>
    );
}

Production Considerations

Authentication

Pass auth headers through the CRDT config:

<CRDTProvider config={{
    baseURL: "/api/sync",
    nodeID: userId,
    tables: ["documents"],
    headers: {
        Authorization: `Bearer ${authToken}`,
    },
}}>

Node ID Strategy

Use a stable, unique identifier per browser session:

// Generate once per session, store in sessionStorage.
function getNodeID(): string {
    let id = sessionStorage.getItem("crdt-node-id");
    if (!id) {
        id = `browser-${crypto.randomUUID().slice(0, 8)}`;
        sessionStorage.setItem("crdt-node-id", id);
    }
    return id;
}

Debouncing Updates

For text inputs, debounce updates to avoid pushing every keystroke:

import { useMemo } from "react";
import { useLWW } from "@grove-js/crdt/react";

function DebouncedEditor({ table, pk, field }: { table: string; pk: string; field: string }) {
    const { value, set } = useLWW<string>(table, pk, field);
    const [local, setLocal] = useState(value ?? "");

    const debouncedSet = useMemo(() => {
        let timer: ReturnType<typeof setTimeout>;
        return (val: string) => {
            clearTimeout(timer);
            timer = setTimeout(() => set(val), 300);
        };
    }, [set]);

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        setLocal(e.target.value);
        debouncedSet(e.target.value);
    };

    // Sync remote changes to local state.
    useEffect(() => {
        if (value !== undefined) setLocal(value);
    }, [value]);

    return <input value={local} onChange={handleChange} />;
}

Error Boundaries

Wrap CRDT components in error boundaries to handle sync failures gracefully:

import { ErrorBoundary } from "react-error-boundary";

function App() {
    return (
        <CRDTProvider config={config}>
            <ErrorBoundary fallback={<div>Sync error. Please refresh.</div>}>
                <DocumentList />
            </ErrorBoundary>
        </CRDTProvider>
    );
}

Adding Presence

To show who else is viewing a document, enable presence on the server and use the useDocumentPresence hook. First, add the presence config to your provider:

<CRDTProvider config={{
    baseURL: "/api/sync",
    nodeID: getNodeID(),
    tables: ["documents"],
    presence: { heartbeatInterval: 10000 },
}}>

Then add active users to the DocumentCard component:

import { useDocumentPresence } from "@grove-js/crdt/react";

function DocumentCard({ id }: { id: string }) {
    const { data, update, remove } = useDocument<Document>("documents", id);
    const { others, updateMyPresence } = useDocumentPresence<{
        name: string;
        color: string;
    }>("documents", id);

    useEffect(() => {
        updateMyPresence({ name: "You", color: "#3b82f6" });
    }, []);

    if (!data) return null;

    return (
        <div className="card">
            <div className="card-header">
                <input
                    value={data.title}
                    onChange={(e) => update("title", e.target.value)}
                />
                <div className="active-users">
                    {others.map((p) => (
                        <span key={p.node_id} style={{ color: p.data.color }}>
                            {p.data.name}
                        </span>
                    ))}
                </div>
            </div>
            {/* ... rest of card ... */}
        </div>
    );
}

The hook automatically leaves the topic when the component unmounts. For a full guide on building typing indicators, cursor tracking, and more, see CRDT: Collaborative Presence.

On this page