@rowsncolumns/loro
v9.0.1
Published
Loro CRDT-based real-time collaboration adapter for Rows n Columns Spreadsheet.
Downloads
55
Readme
@rowsncolumns/loro
Loro CRDT-based real-time collaboration adapter for Rows n Columns Spreadsheet.
This adapter stores spreadsheet data directly in a Loro document using CRDTs (Conflict-free Replicated Data Types) for real-time synchronization.
Features
- Real-time collaboration with CRDT-based conflict resolution
- Flat map data structure for efficient cell operations
- Presence awareness (see other users' cursors)
- Leader election for recalc coordination
- Full offline support - work offline and sync later
- Full support for sheets, tables, charts, embeds, named ranges, and more
Why Loro for Spreadsheets?
Loro is a high-performance CRDT library that provides automatic conflict resolution without requiring a central server.
Comparison with ShareDB (OT)
| Aspect | Loro (CRDT) | ShareDB (OT) | | ----------------------- | ------------------------------------- | --------------------------------- | | Conflict resolution | Automatic merge, eventual consistency | Server determines canonical order | | Offline support | Full offline-first support | Requires server connection | | Server required | Optional (can sync peer-to-peer) | Yes, server is mandatory | | Performance | Highly optimized Rust core (WASM) | JavaScript-based | | History/Time travel | Built-in version history | Manual operation log | | Recalculation | Leader election for coordination | Leader election for coordination |
When to Choose Loro
- Offline-first requirements - Users need to work without connectivity
- High performance - Loro's Rust core provides excellent performance
- Flexible sync - Can work with any transport (WebSocket, WebRTC, etc.)
- Version history - Built-in support for time travel and history
When to Choose ShareDB
- Server authority - Need central server to be source of truth
- Existing infrastructure - Already using MongoDB/PostgreSQL with ShareDB
- Simpler mental model - OT's sequential operations may be easier to reason about
Installation
npm install @rowsncolumns/loro loro-crdtQuick Start
import { useLoro } from "@rowsncolumns/loro";
import { LoroDoc } from "loro-crdt";
// Create a Loro document
const doc = new LoroDoc();
function SpreadsheetEditor() {
const [sheetData, setSheetData] = useState({});
const [sheets, setSheets] = useState([]);
const [tables, setTables] = useState([]);
const { onBroadcastPatch, users, synced, isLeader, importUpdate } = useLoro({
doc,
userId: "user-123",
title: "John Doe",
sheetId: 1,
activeCell: { rowIndex: 1, columnIndex: 1 },
initialSheets: [],
onChangeSheetData: setSheetData,
onChangeSheets: setSheets,
onChangeTables: setTables,
enqueueGraphOperation: (op) => {
// Handle dependency graph updates
},
calculateNow: async () => {
// Trigger recalculation
},
// Optional: handle sync updates for broadcasting to other clients
onSyncUpdate: (update) => {
// Send update to server/peers
websocket.send(update);
},
// Optional: handle presence updates
onPresenceUpdate: (presence) => {
websocket.send(JSON.stringify({ type: "presence", data: presence }));
},
remotePresences: {}, // Presences from other clients
});
// Pass onBroadcastPatch to your spreadsheet component
return (
<Spreadsheet
sheetData={sheetData}
sheets={sheets}
onBroadcastPatch={onBroadcastPatch}
users={users}
/>
);
}Setting Up a Sync Server
Basic WebSocket Server
Create a file server.ts:
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"),
}),
);
}
// Send existing presences
const presences = documentPresences.get(message.docId);
if (presences) {
for (const [userId, data] of presences.entries()) {
if (userId !== message.userId) {
ws.send(JSON.stringify({ type: "presence", userId, data }));
}
}
}
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(message));
}
}
}
break;
}
case "presence": {
// 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(message));
}
}
}
break;
}
}
});
ws.on("close", () => {
if (currentDocId && currentUserId) {
// Remove client and broadcast presence leave
documentClients.get(currentDocId)?.delete(ws);
documentPresences.get(currentDocId)?.delete(currentUserId);
const clients = documentClients.get(currentDocId);
if (clients) {
for (const client of clients) {
client.send(
JSON.stringify({
type: "presence-leave",
userId: currentUserId,
}),
);
}
}
}
});
});
server.listen(8081, () => {
console.log("Loro sync server running on port 8081");
});Run the Server
npx tsx server.tsOr add to your package.json:
{
"scripts": {
"server": "tsx server.ts"
}
}Document Structure
The Loro document uses V3 format with a flat map for cell data:
type LoroSpreadsheetDoc = {
// Cell data: flat map with keys like "1!A1" -> CellDataV3
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>;
// Recalc coordination
recalcCells: RecalcCellEntry[];
};Cell Key Format (V3)
Cell keys follow the pattern ${sheetId}!${A1Address}:
"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 wrapped in a CellDataV3 object with flattened position:
type CellDataV3<T> = {
value: T; // The cell data (formula, value, formatting, etc.)
sId: number; // Sheet ID
r: number; // Row index
c: number; // Column index
};API Reference
useLoro
const {
onBroadcastPatch,
users,
synced,
isLeader,
importUpdate,
exportUpdate,
exportSnapshot,
} = useLoro(props);Props
| Prop | Type | Required | Description |
| ---------------------------- | -------------------------------------- | -------- | -------------------------------------- |
| doc | LoroDoc | Yes | Loro document instance |
| userId | string \| number | Yes | Current user's ID |
| title | string | Yes | User's display name |
| sheetId | number | Yes | Currently active sheet ID |
| activeCell | CellInterface | No | Current active cell position |
| initialSheets | Sheet[] | Yes | Initial sheets if document is empty |
| disable | boolean | No | Disable collaboration |
| onChangeSheetData | Dispatch<SetStateAction> | Yes | Sheet data state setter |
| onChangeSheets | Dispatch<SetStateAction> | Yes | Sheets state setter |
| onChangeTables | Dispatch<SetStateAction> | Yes | Tables state setter |
| onChangeCharts | Dispatch<SetStateAction> | No | Charts state setter |
| onChangeEmbeds | Dispatch<SetStateAction> | No | Embeds state setter |
| onChangeNamedRanges | Dispatch<SetStateAction> | No | Named ranges setter |
| onChangeProtectedRanges | Dispatch<SetStateAction> | No | Protected ranges setter |
| onChangeConditionalFormats | Dispatch<SetStateAction> | No | Conditional formats setter |
| onChangeDataValidations | Dispatch<SetStateAction> | No | Data validations setter |
| onChangePivotTables | Dispatch<SetStateAction> | No | Pivot tables setter |
| onChangeCellXfs | Dispatch<SetStateAction> | No | Cell formats setter |
| onChangeSharedStrings | Dispatch<SetStateAction> | No | Shared strings setter |
| onChangeActiveSheet | (sheetId: number) => void | No | Active sheet change handler |
| enqueueGraphOperation | (op: CalculationOperation) => void | Yes | Graph operation handler |
| calculateNow | (options?) => Promise<void> | Yes | Trigger recalculation |
| onSyncUpdate | (update: Uint8Array) => void | No | Callback for sync updates to broadcast |
| onPresenceUpdate | (presence: LoroPresenceData) => void | No | Callback for presence updates |
| remotePresences | Record<string, LoroPresenceData> | No | Remote presences from other clients |
| onError | (err: unknown) => void | No | Error callback |
Return Value
| Property | Type | Description |
| ------------------ | ------------------------------ | ----------------------------------- |
| onBroadcastPatch | Function | Callback to broadcast local changes |
| users | Collaborator[] | List of connected collaborators |
| synced | boolean | Whether initial sync is complete |
| isLeader | boolean | Whether this client is the leader |
| importUpdate | (update: Uint8Array) => void | Import an update from a remote peer |
| exportUpdate | () => Uint8Array \| null | Export current state as update |
| exportSnapshot | () => Uint8Array \| null | Export current state as snapshot |
Leader Election
The adapter uses a simple leader election mechanism based on user IDs. The client with the alphabetically first user ID becomes the leader. The leader is responsible for:
- Coordinating recalc cell cleanup
- Full recalculation operations
const { isLeader } = useLoro({ ... });
if (isLeader) {
// This client is responsible for leader tasks
}Presence Awareness
Connected users are exposed via the users array:
const { users } = useLoro({ ... });
// users: Collaborator[]
// Each collaborator has: { userId, title, sheetId, activeCell }Offline Support
Loro CRDTs work fully offline. Changes made while offline are stored locally and automatically merged when reconnecting:
// Work offline - changes are stored locally
onBroadcastPatch(patches);
// When back online, export and sync
const update = exportUpdate();
if (update) {
websocket.send(update);
}
// Import updates from server
websocket.onmessage = (event) => {
importUpdate(new Uint8Array(event.data));
};Error Handling
Provide an onError callback to handle errors:
useLoro({
// ...
onError: (err) => {
console.error("Loro error:", err);
// Handle error (show toast, reconnect, etc.)
},
});Helper Functions
The adapter exports helper functions for working with Loro documents:
import {
applyLoroUpdate,
exportLoroUpdate,
exportLoroSnapshot,
} from "@rowsncolumns/loro";
// Apply an update from a remote peer
applyLoroUpdate(doc, update);
// Export current state as update (for incremental sync)
const update = exportLoroUpdate(doc);
// Export current state as snapshot (for full sync)
const snapshot = exportLoroSnapshot(doc);License
MIT
