Docs
Tutorial
Getting Started

Getting Started

⚠️ Notice: The current API and encoding schema of Loro are experimental and subject to change. You should not use it in production.

You can use Loro in your web application by importing the loro-crdt package.

Due to our current limited team size, we are focusing on the web platform. Other platforms will be supported in the future.

npm install loro-crdt
 
# Or
pnpm install loro-crdt
 
# Or
yarn add loro-crdt

If you're using Vite, you should add the following to your vite.config.ts:

import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
 
export default defineConfig({
  plugins: [...otherConfigures, wasm(), topLevelAwait()],
});

If you're using Next.js, you should add the following to your next.config.js:

 
module.exports = {
  webpack: function (config) {
    config.experiments = {
      layers: true,
      asyncWebAssembly: true,
    };
    return config;
  },
};

You can find the examples of basic usage in Loro examples in Deno (opens in a new tab).

Loro is compatible with the JSON schema. Your state should also adhere to the JSON schema to be able to use Loro. For instance, using a number as a key in a Map is not permitted, and cyclic links should be avoided.

Currently, Loro is agnostic to the network layer and storage methods, which allows it to easily be compatible with multiple platforms. If you need to save new content, use doc.exportSnapshot() or doc.exportFrom(). These interfaces export binary data, which you can store or send in any way you prefer. If you need to load remote updates or locally stored data, use doc.import().

LoroDoc

LoroDoc is the entry point for using Loro. You must create a Doc to use Map, List, Text, and other types and to complete data synchronization.

const doc = new Loro();
const text: LoroText = doc.getText("text");
text.insert(0, "Hello world!");
console.log(doc.toJSON()); // { "text": "Hello world!" }

You cannot directly create a LoroText using new LoroText(). This is because the document state and Ops are all recorded on LoroDoc. The LoroText type is merely a convenient way to operate on the Doc.

Container

We refer to CRDT types such as List, Map, and Text as "Containers".

Here are their basic operations:

const doc = new Loro();
const list: LoroList = doc.getList("list");
list.insert(0, "A");
list.insert(1, "B");
list.insert(2, "C");
 
const map: LoroMap = doc.getMap("map");
// map can only has string key
map.set("key", "value");
expect(doc.toJSON()).toStrictEqual({
  list: ["A", "B", "C"],
  map: { key: "value" },
});
 
// delete 2 element at index 0
list.delete(0, 2);
expect(doc.toJSON()).toStrictEqual({
  list: ["C"],
  map: { key: "value" },
});
 
// Insert a text container to the list
const text = list.insertContainer(0, new LoroText());
text.insert(0, "Hello");
text.insert(0, "Hi! ");
 
expect(doc.toJSON()).toStrictEqual({
  list: ["Hi! Hello", "C"],
  map: { key: "value" },
});
 
// Insert a list container to the map
const list2 = map.setContainer("test", new LoroList());
list2.insert(0, 1);
expect(doc.toJSON()).toStrictEqual({
  list: ["Hi! Hello", "C"],
  map: { key: "value", test: [1] },
});

Save and Load

To save the document, use doc.exportSnapshot() to get its binary form. To open it again, use doc.import(data) to load this binary data.

const doc = new Loro();
doc.getText("text").insert(0, "Hello world!");
const data = doc.exportSnapshot();
 
const newDoc = new Loro();
newDoc.import(data);
expect(newDoc.toJSON()).toStrictEqual({
  text: "Hello world!",
});

Exporting the entire document on each keypress is inefficient. Instead, use doc.exportFrom() to obtain binary data for operations since the last export.

const doc = new Loro();
doc.getText("text").insert(0, "Hello world!");
const data = doc.exportSnapshot();
let lastSavedVersion = doc.version();
doc.getText("text").insert(0, "✨");
const update0 = doc.exportFrom(lastSavedVersion);
lastSavedVersion = doc.version();
doc.getText("text").insert(0, "😶‍🌫️");
const update1 = doc.exportFrom(lastSavedVersion);
 
{
  /**
   * You can import the snapshot and the updates to get the latest version of the document.
   */
 
  // import the snapshot
  const newDoc = new Loro();
  newDoc.import(data);
  expect(newDoc.toJSON()).toStrictEqual({
    text: "Hello world!",
  });
 
  // import update0
  newDoc.import(update0);
  expect(newDoc.toJSON()).toStrictEqual({
    text: "✨Hello world!",
  });
 
  // import update1
  newDoc.import(update1);
  expect(newDoc.toJSON()).toStrictEqual({
    text: "😶‍🌫️✨Hello world!",
  });
}
 
{
  /**
   * You may also import them in a batch
   */
  const newDoc = new Loro();
  newDoc.importUpdateBatch([update1, update0, data]);
  expect(newDoc.toJSON()).toStrictEqual({
    text: "😶‍🌫️✨Hello world!",
  });
}

If updates accumulate, exporting a new snapshot can quicken import times and decrease the overall size of the exported data.

You can store the binary data exported from Loro wherever you prefer.

Sync

Two documents with concurrent edits can be synchronized by just two message exchanges.

Below is an example of synchronization between two documents:

const docA = new Loro();
const docB = new Loro();
const listA: LoroList = docA.getList("list");
listA.insert(0, "A");
listA.insert(1, "B");
listA.insert(2, "C");
// B import the ops from A
const data: Uint8Array = docA.exportFrom();
// The data can be sent to B through the network
docB.import(data);
expect(docB.toJSON()).toStrictEqual({
  list: ["A", "B", "C"],
});
 
const listB: LoroList = docB.getList("list");
listB.delete(1, 1);
 
// `doc.exportFrom(version)` can encode all the ops from the version to the latest version
// `version` is the version vector of another document
const missingOps = docB.exportFrom(docA.version());
docA.import(missingOps);
 
expect(docA.toJSON()).toStrictEqual({
  list: ["A", "C"],
});
expect(docA.toJSON()).toStrictEqual(docB.toJSON());

Event

You can subscribe to the event from Containers.

LoroText and LoroList can receive updates in Quill Delta (opens in a new tab) format.

Below is an example of rich text event:

// The code is from https://github.com/loro-dev/loro-examples-deno
const doc = new Loro();
const text = doc.getText("text");
text.insert(0, "Hello world!");
doc.commit();
let ran = false;
text.subscribe((e) => {
  for (const event of e.events) {
    if (event.diff.type === "text") {
      expect(event.diff.diff).toStrictEqual([
        {
          retain: 5,
          attributes: { bold: true },
        },
      ]);
      ran = true;
    }
  }
});
text.mark({ start: 0, end: 5 }, "bold", true);
doc.commit();
await new Promise((r) => setTimeout(r, 1));
expect(ran).toBeTruthy();

The types of events are defined as follows:

export interface LoroEvent {
  /**
   * If true, this event was triggered by a local change.
   */
  local: boolean;
  origin?: string;
  /**
   * If true, this event was triggered by a child container.
   */
  fromChildren: boolean;
  /**
   * If true, this event was triggered by a checkout.
   */
  fromCheckout: boolean;
  diff: Diff;
  target: ContainerID;
  path: Path;
}
 
export type Path = (number | string)[];
 
export type Diff = ListDiff | TextDiff | MapDiff | TreeDiff;
 
export type ListDiff = {
  type: "list";
  diff: Delta<Value[]>[];
};
 
export type TextDiff = {
  type: "text";
  diff: Delta<string>[];
};
 
export type MapDiff = {
  type: "map";
  updated: Record<string, Value | undefined>;
};
 
export type TreeDiff = {
  type: "tree";
  diff:
    | { target: TreeID; action: "create" | "delete" }
    | { target: TreeID; action: "move"; parent: TreeID };
};
 
export type Delta<T> =
  | {
      insert: T;
      attributes?: { [key in string]: {} };
      retain?: undefined;
      delete?: undefined;
    }
  | {
      delete: number;
      attributes?: undefined;
      retain?: undefined;
      insert?: undefined;
    }
  | {
      retain: number;
      attributes?: { [key in string]: {} };
      delete?: undefined;
      insert?: undefined;
    };