---
title: TypeScript Client
description: 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

```bash
npm install @grove-js/crdt
```

React hooks require React 18+ as a peer dependency:

```bash
npm install react
```

## Core Modules

The package exports from two entry points:

```typescript
// 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:

```typescript
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

| Option | Type | Description |
|--------|------|-------------|
| `baseURL` | `string` | Sync server URL. Required when no custom `transport` is provided. |
| `nodeID` | `string` | Unique client identifier |
| `tables` | `string[]` | Tables to sync |
| `headers` | `Record<string, string>` | Custom headers (used by built-in `HttpTransport`) |
| `fetch` | `typeof fetch` | Custom fetch implementation (used by built-in `HttpTransport`) |
| `transport` | `Transport` | Custom transport for pull/push operations. When provided, `baseURL` is not required. |
| `streamTransport` | `StreamTransport` | Custom stream transport for real-time subscriptions |
| `auth` | `AuthProvider` | Auth provider for dynamic header injection (e.g., JWT refresh) |
| `storage` | `StorageAdapter` | Storage adapter for persistence (IndexedDB, localStorage, etc.) |
| `presence` | `PresenceConfig` | Presence 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

```typescript
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:

```typescript
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:

```typescript
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):

```typescript
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:

```typescript
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:

```typescript
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

| Method | Description |
|--------|-------------|
| `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`:

```typescript
// 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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
import {
    CRDTProvider,
    useDocument,
    useCollection,
    useLWW,
    useCounter,
    useSet,
    useSyncStatus,
    useStream,
} from "@grove-js/crdt/react";
```

### CRDTProvider

Wraps your app with the CRDT context:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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](/docs/grove/crdt/presence) for the full guide.

```tsx
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"`:

```tsx
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:

```typescript
// 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:

```typescript
// 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

```tsx
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:

```typescript
// 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:

```typescript
// 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

```tsx
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:

```typescript
// 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:

```typescript
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](/docs/grove/crdt/rooms) for the full guide.

```typescript
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

```tsx
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:

```typescript
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

```typescript
// 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

```typescript
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

```typescript
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

```tsx
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:

```tsx
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:

```typescript
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

| Code | Class | Description |
|------|-------|-------------|
| `NETWORK_ERROR` | `NetworkError` | HTTP request failed or timed out |
| `NETWORK_TIMEOUT` | `NetworkError` | Request exceeded timeout |
| `SYNC_CONFLICT` | `SyncError` | Unresolvable sync conflict |
| `SYNC_VERSION_MISMATCH` | `SyncError` | Protocol version mismatch |
| `VALIDATION_FAILED` | `ValidationError` | Field validation rule violated |
| `VALIDATION_SCHEMA` | `ValidationError` | Schema mismatch |
| `PLUGIN_INIT_FAILED` | `PluginError` | Plugin initialization error |
| `PLUGIN_HOOK_FAILED` | `PluginError` | Plugin hook threw an error |

## Full Example

A collaborative document editor with real-time CRDT sync:

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