# 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
