# Loro (CRDT) Collaboration

Loro is a high-performance CRDT library that enables real-time collaboration with automatic conflict resolution. The `@rowsncolumns/loro` package provides a React hook for integrating Loro with your spreadsheet.

## Installation

{% tabs %}
{% tab title="yarn" %}

```sh
yarn add @rowsncolumns/loro loro-crdt
```

{% endtab %}

{% tab title="npm" %}

```sh
npm install @rowsncolumns/loro loro-crdt
```

{% endtab %}
{% endtabs %}

## Quick Start

```tsx
import { useLoro } from "@rowsncolumns/loro";
import { LoroDoc } from "loro-crdt";

// Create a Loro document
const loroDoc = new LoroDoc();

function SpreadsheetEditor() {
  const [sheetData, setSheetData] = useState({});
  const [sheets, setSheets] = useState([]);
  const [tables, setTables] = useState([]);
  const [sheetId, setSheetId] = useState(1);
  const [activeCell, setActiveCell] = useState({ rowIndex: 1, columnIndex: 1 });

  const { onBroadcastPatch, users, synced, isLeader, importUpdate } = useLoro({
    doc: loroDoc,
    userId: "user-123",
    title: "John Doe",
    sheetId,
    activeCell,
    initialSheets: [],
    onChangeSheetData: setSheetData,
    onChangeSheets: setSheets,
    onChangeTables: setTables,
    onChangeActiveSheet: setSheetId,
    calculateNow,
    enqueueGraphOperation: (op) => {
      // Handle dependency graph updates for formula recalculation
    },
    onSyncUpdate: (update) => {
      // Send update to server
      websocket.send(update);
    },
  });

  // Pass onBroadcastPatch to useSpreadsheetState
  return <Spreadsheet sheetData={sheetData} sheets={sheets} users={users} />;
}
```

## Integration with useSpreadsheetState

Connect the Loro adapter to your spreadsheet state:

```tsx
import { useSpreadsheetState } from "@rowsncolumns/spreadsheet-state";
import { useLoro } from "@rowsncolumns/loro";
import { LoroDoc } from "loro-crdt";

const loroDoc = new LoroDoc();

function CollaborativeSpreadsheet() {
  const {
    sheetData,
    sheets,
    tables,
    charts,
    embeds,
    namedRanges,
    protectedRanges,
    conditionalFormats,
    dataValidations,
    pivotTables,
    cellXfs,
    sharedStrings,
    setSheetData,
    setSheets,
    setTables,
    setCharts,
    setEmbeds,
    setNamedRanges,
    setProtectedRanges,
    setConditionalFormats,
    setDataValidations,
    setPivotTables,
    setCellXfs,
    setSharedStrings,
    enqueueGraphOperation,
    sheetId,
    activeCell,
    calculateNow,
  } = useSpreadsheetState({
    // Your spreadsheet state config
  });

  // WebSocket connection state
  const [remotePresences, setRemotePresences] = useState({});
  const wsRef = useRef<WebSocket | null>(null);

  const handleSyncUpdate = useCallback((update: Uint8Array) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      // Encode update as base64 for JSON transport
      wsRef.current.send(
        JSON.stringify({
          type: "update",
          docId: "my-doc",
          data: btoa(String.fromCharCode(...update)),
        }),
      );
    }
  }, []);

  const handlePresenceUpdate = useCallback((presence) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(
        JSON.stringify({
          type: "presence",
          docId: "my-doc",
          userId: presence.userId,
          data: presence,
        }),
      );
    }
  }, []);

  const { onBroadcastPatch, users, synced, importUpdate } = useLoro({
    doc: loroDoc,
    userId: currentUser.id,
    title: currentUser.name,
    sheetId,
    activeCell,
    initialSheets: [],
    calculateNow,
    onChangeSheetData: setSheetData,
    onChangeSheets: setSheets,
    onChangeTables: setTables,
    onChangeCharts: setCharts,
    onChangeEmbeds: setEmbeds,
    onChangeNamedRanges: setNamedRanges,
    onChangeProtectedRanges: setProtectedRanges,
    onChangeConditionalFormats: setConditionalFormats,
    onChangeDataValidations: setDataValidations,
    onChangePivotTables: setPivotTables,
    onChangeCellXfs: setCellXfs,
    onChangeSharedStrings: setSharedStrings,
    enqueueGraphOperation,
    onSyncUpdate: handleSyncUpdate,
    onPresenceUpdate: handlePresenceUpdate,
    remotePresences,
  });

  // WebSocket connection
  useEffect(() => {
    const ws = new WebSocket("ws://localhost:8081");
    wsRef.current = ws;

    ws.onopen = () => {
      ws.send(
        JSON.stringify({
          type: "join",
          docId: "my-doc",
          userId: currentUser.id,
        }),
      );
    };

    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      switch (message.type) {
        case "update":
        case "snapshot":
          const data = Uint8Array.from(atob(message.data), (c) =>
            c.charCodeAt(0),
          );
          importUpdate(data);
          break;
        case "presence":
          setRemotePresences((prev) => ({
            ...prev,
            [message.userId]: message.data,
          }));
          break;
        case "presence-leave":
          setRemotePresences((prev) => {
            const next = { ...prev };
            delete next[message.userId];
            return next;
          });
          break;
      }
    };

    return () => ws.close();
  }, [importUpdate]);

  // Pass onBroadcastPatch to your spreadsheet
  // ...
}
```

## Setting Up a Loro Sync Server

### Basic Server

Create a file `server.ts`:

```typescript
import http from "http";
import express from "express";
import { WebSocketServer, WebSocket } from "ws";
import { LoroDoc } from "loro-crdt";

// Store documents in memory (use a database in production)
const documents = new Map<string, Uint8Array>();
const documentClients = new Map<string, Set<WebSocket>>();
const documentPresences = new Map<string, Map<string, unknown>>();

const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });

wss.on("connection", (ws) => {
  let currentDocId: string | null = null;
  let currentUserId: string | null = null;

  ws.on("message", (rawData) => {
    const message = JSON.parse(rawData.toString());

    switch (message.type) {
      case "join": {
        currentDocId = message.docId;
        currentUserId = message.userId;

        // Add client to document
        if (!documentClients.has(message.docId)) {
          documentClients.set(message.docId, new Set());
        }
        documentClients.get(message.docId)!.add(ws);

        // Send current document state
        const state = documents.get(message.docId);
        if (state) {
          ws.send(
            JSON.stringify({
              type: "snapshot",
              docId: message.docId,
              data: Buffer.from(state).toString("base64"),
            }),
          );
        }

        // Initialize and send existing presences
        if (!documentPresences.has(message.docId)) {
          documentPresences.set(message.docId, new Map());
        }
        const presences = documentPresences.get(message.docId)!;
        for (const [userId, data] of presences.entries()) {
          if (userId !== message.userId) {
            ws.send(
              JSON.stringify({
                type: "presence",
                docId: message.docId,
                userId,
                data,
              }),
            );
          }
        }

        // Store and broadcast new user's presence
        presences.set(message.userId, { userId: message.userId });
        const clients = documentClients.get(message.docId);
        if (clients) {
          for (const client of clients) {
            if (client !== ws && client.readyState === WebSocket.OPEN) {
              client.send(
                JSON.stringify({
                  type: "presence",
                  docId: message.docId,
                  userId: message.userId,
                  data: { userId: message.userId },
                }),
              );
            }
          }
        }
        break;
      }

      case "update": {
        if (!message.docId) break;

        // Decode and apply update
        const update = Buffer.from(message.data, "base64");

        // Update server's copy
        let serverDoc = new LoroDoc();
        const existingState = documents.get(message.docId);
        if (existingState) {
          serverDoc.import(existingState);
        }
        serverDoc.import(update);
        documents.set(message.docId, serverDoc.export({ mode: "snapshot" }));

        // Broadcast to other clients
        const clients = documentClients.get(message.docId);
        if (clients) {
          for (const client of clients) {
            if (client !== ws && client.readyState === WebSocket.OPEN) {
              client.send(
                JSON.stringify({
                  type: "update",
                  docId: message.docId,
                  data: message.data,
                }),
              );
            }
          }
        }
        break;
      }

      case "presence": {
        if (!message.docId) break;

        // Store and broadcast presence
        if (!documentPresences.has(message.docId)) {
          documentPresences.set(message.docId, new Map());
        }
        documentPresences.get(message.docId)!.set(message.userId, message.data);

        const clients = documentClients.get(message.docId);
        if (clients) {
          for (const client of clients) {
            if (client !== ws && client.readyState === WebSocket.OPEN) {
              client.send(
                JSON.stringify({
                  type: "presence",
                  docId: message.docId,
                  userId: message.userId,
                  data: message.data,
                }),
              );
            }
          }
        }
        break;
      }
    }
  });

  ws.on("close", () => {
    if (currentDocId && currentUserId) {
      // Remove client
      documentClients.get(currentDocId)?.delete(ws);
      documentPresences.get(currentDocId)?.delete(currentUserId);

      // Broadcast presence leave
      const clients = documentClients.get(currentDocId);
      if (clients) {
        for (const client of clients) {
          if (client.readyState === WebSocket.OPEN) {
            client.send(
              JSON.stringify({
                type: "presence-leave",
                docId: currentDocId,
                userId: currentUserId,
              }),
            );
          }
        }
      }
    }
  });
});

const PORT = process.env.PORT || 8081;
server.listen(PORT, () => {
  console.log(`Loro sync server listening on port ${PORT}`);
});
```

Install dependencies:

```bash
npm install express ws loro-crdt tsx
npm install -D @types/express @types/ws
```

Run the server:

```bash
npx tsx server.ts
```

### Server with Redis Persistence

For production environments, persist Loro snapshots to Redis:

```bash
npm install ioredis
```

```typescript
import Redis from "ioredis";

const redis = new Redis();

// Load document from Redis
async function loadDocument(docId: string): Promise<Uint8Array | null> {
  const data = await redis.getBuffer(`loro:${docId}`);
  return data ? new Uint8Array(data) : null;
}

// Save document to Redis
async function saveDocument(docId: string, snapshot: Uint8Array): Promise<void> {
  await redis.set(`loro:${docId}`, Buffer.from(snapshot));
}

// In the update handler:
case "update": {
  // ... apply update ...
  const snapshot = serverDoc.export({ mode: "snapshot" });
  documents.set(message.docId, snapshot);
  await saveDocument(message.docId, snapshot);
  // ... broadcast ...
}
```

### Server with PostgreSQL Persistence

```bash
npm install pg
```

```typescript
import { Pool } from "pg";

const pool = new Pool({
  connectionString: "postgresql://user:password@localhost:5432/spreadsheets",
});

// Create table
await pool.query(`
  CREATE TABLE IF NOT EXISTS loro_documents (
    doc_id TEXT PRIMARY KEY,
    snapshot BYTEA NOT NULL,
    updated_at TIMESTAMP DEFAULT NOW()
  )
`);

// Save document
async function saveDocument(docId: string, snapshot: Uint8Array) {
  await pool.query(
    `INSERT INTO loro_documents (doc_id, snapshot, updated_at)
     VALUES ($1, $2, NOW())
     ON CONFLICT (doc_id) DO UPDATE SET snapshot = $2, updated_at = NOW()`,
    [docId, Buffer.from(snapshot)],
  );
}

// Load document
async function loadDocument(docId: string): Promise<Uint8Array | null> {
  const result = await pool.query(
    "SELECT snapshot FROM loro_documents WHERE doc_id = $1",
    [docId],
  );
  return result.rows[0]?.snapshot || null;
}
```

## Document Structure

The Loro document stores all spreadsheet data:

```typescript
type LoroSpreadsheetDoc = {
  // Cell data: flat map with keys like "1!A1" -> { value, sId, r, c }
  sheetData: Record<string, CellDataV3>;

  // Sheet definitions
  sheets: Sheet[];

  // Tables, charts, embeds
  tables: TableView[];
  charts: EmbeddedChart[];
  embeds: EmbeddedObject[];

  // Named ranges, protected ranges
  namedRanges: NamedRange[];
  protectedRanges: ProtectedRange[];

  // Conditional formats, data validations
  conditionalFormats: ConditionalFormatRule[];
  dataValidations: DataValidationRuleRecord[];

  // Pivot tables
  pivotTables: PivotTable[];

  // Cell formats and shared strings
  cellXfs: Record<string, CellFormat>;
  sharedStrings: Record<string, string>;
};
```

### Cell Key Format

Cell keys follow the pattern `${sheetId}!${A1Address}`:

| Key         | Description           |
| ----------- | --------------------- |
| `"1!A1"`    | Cell A1 on sheet 1    |
| `"2!B5"`    | Cell B5 on sheet 2    |
| `"1!AA100"` | Cell AA100 on sheet 1 |

### CellDataV3 Structure

Each cell value is stored with its position metadata:

```typescript
type CellDataV3<T> = {
  value: T; // The cell data (text, formula, number, etc.)
  sId: number; // Sheet ID
  r: number; // Row index (0-based)
  c: number; // Column index (0-based)
};
```

## Presence Awareness

The hook automatically manages presence, allowing you to display other users' cursor positions:

```tsx
const { users } = useLoro({ ... });

// Render collaborators in your grid
<CanvasGrid
  users={users}
  userId={currentUserId}
/>
```

Each user in the `users` array includes:

```typescript
type Collaborator = {
  userId: string;
  title: string;
  sheetId: number;
  activeCell: { rowIndex: number; columnIndex: number };
};
```

## Leader Election

The adapter automatically elects a leader among connected clients. The leader is responsible for coordinating recalculation operations.

```tsx
const { isLeader } = useLoro({ ... });

// Leader-specific logic
if (isLeader) {
  // This client is responsible for coordinating recalcs
}
```

## Offline Support

Loro CRDTs work fully offline. Changes are stored locally and automatically merged when reconnecting:

```tsx
// Work offline - changes are stored in the local Loro document
onBroadcastPatch(patches);

// When back online, export and sync
const { exportUpdate, importUpdate } = useLoro({ ... });

// Send local changes to server
const update = exportUpdate();
if (update) {
  websocket.send(update);
}

// Receive and merge remote changes
websocket.onmessage = (event) => {
  importUpdate(new Uint8Array(event.data));
};
```

## Error Handling

Handle errors with the `onError` callback:

```tsx
useLoro({
  // ...
  onError: (err) => {
    console.error("Loro error:", err);
    toast.error("Sync error. Will retry...");
  },
});
```

## Why Loro for Spreadsheets?

Loro's [CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type) approach offers unique advantages for spreadsheet collaboration.

### Comparison with ShareDB

| Aspect              | Loro (CRDT)                               | ShareDB (OT)                      |
| ------------------- | ----------------------------------------- | --------------------------------- |
| Conflict Resolution | Automatic merge, eventual consistency     | Server determines canonical order |
| Server Required     | Optional (can sync peer-to-peer)          | Yes                               |
| Offline Support     | Full offline-first support                | Requires server connection        |
| Performance         | Rust core compiled to WASM                | Pure JavaScript                   |
| Recalculation       | Leader election for coordination          | Leader election for coordination  |
| History             | Built-in version history and time travel  | Manual operation log              |
| Data Persistence    | Any storage (Redis, PostgreSQL, S3, etc.) | MongoDB, PostgreSQL via adapters  |

### Why CRDTs Work Well for Spreadsheets

1. **Cell-level granularity** - The V3 flat map structure (`"sheetId!A1"` keys) maps perfectly to Loro's Map CRDT. Each cell is independent, minimizing conflicts.
2. **Offline-first** - Users can work without connectivity and sync later. All changes are preserved and merged automatically.
3. **High performance** - Loro's Rust core provides excellent performance for large documents and many concurrent operations.
4. **Flexible architecture** - No central server required. Can use WebSocket, WebRTC, or any transport.
5. **Built-in history** - Time travel and version history are built into the CRDT.

### When to Choose Loro

* Need offline-first capabilities
* Want high-performance CRDT operations
* Need flexible sync (WebSocket, WebRTC, etc.)
* Decentralized architecture is preferred
* Need built-in version history

### When to Choose ShareDB

* Need traditional client-server architecture
* Want to leverage existing MongoDB/PostgreSQL infrastructure
* Prefer OT semantics for conflict resolution
* Need server-side authority for all operations


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.rowsncolumns.app/collaboration/loro-collaboration.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
