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 reactApplication 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, andCRDTStore - 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:
- User A types in the title input
update("title", "New Title")callsstore.setField(), creating a change with a new HLC- The 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 screen- 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.