This guide builds a complete collaborative document editor using the @grove-js/crdt TypeScript client with React hooks, connected to a Grove Go backend.
Prerequisites01
A Grove Go server with CRDT sync endpoints (see Getting Started)
Node.js 18+ and React 18+
Project Setup02
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 reactApplication Root03
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, andCRDTStorePulls initial data on mount
Connects to the SSE stream for real-time updates
Sync Status Bar04
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 List05
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 Card06
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 Tracker07
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 Editor08
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 Editor09
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-End10
Here is what happens when User A edits a title and User B sees the update:
User A types in the title input
update("title", "New Title")callsstore.setField(), creating a change with a new HLCThe store notifies subscribers, React re-renders the component
The provider periodically calls
client.push(store.getPendingChanges())The Go server receives the push, merges via
SyncController.HandlePush()The SSE stream detects the new change and sends an event
User B's
CRDTStreamreceives the event, callsstore.applyChanges()useSyncExternalStoretriggers React re-render on User B's screenUser 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 Offline11
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 Considerations12
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 Presence13
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.