Define a schema and instantiate a Mirror with a LoroDoc.
import { Mirror, schema } from "loro-mirror";
import { LoroDoc } from "loro-crdt";
const doc = new LoroDoc();
const appSchema = schema({
// LoroMap: structured object
settings: schema.LoroMap({
title: schema.String({ defaultValue: "Docs" }),
darkMode: schema.Boolean({ defaultValue: false }),
}),
// LoroList: array of items (use `$cid` from maps)
todos: schema.LoroList(
schema.LoroMap({
text: schema.String(),
}),
(t) => t.$cid, // `$cid` reuses Loro container id (explained later)
),
// LoroText: collaborative text (string in state)
notes: schema.LoroText(),
});
const store = new Mirror({ doc, schema: appSchema });
// Read state
const state = store.getState();
// Update (return a new state) — synchronous; the next line sees the new state.
store.setState({
...state,
settings: { ...state.settings, darkMode: true },
todos: [...state.todos, { text: "Add milk" }],
notes: "Hello, team!",
});
// Or mutate a draft (Immer-style)
store.setState((s) => {
s.todos.push({ text: "Ship" });
s.settings.title = "Project";
});
// Subscribe
const unsubscribe = store.subscribe((next, { source }) => {
// source: "LORO" | "MIRROR" | "EPHEMERAL"
});npm install loro-mirror loro-crdtCore structures first:
- LoroMap: Structured object synced to a Loro map. Update fields by setting plain JS values. Nested containers (maps/lists/text) are created automatically from schema.
- LoroList: Array of items. With an
idSelector, Mirror performs minimal add/update/move/delete; without it, updates are by index. - LoroText: Collaborative rich text represented as a string in state; Mirror calls
LoroText.updateon changes.
Trees are advanced usage; see Advanced: Trees at the end.
- Constructor:
new Mirror({ doc, schema?, initialState?, validateUpdates?=true, debug?=false, checkStateConsistency?=false, inferOptions? })- doc: LoroDoc to sync with
- schema: Root schema; enables validation and typed defaults
- initialState: Shallow-merged over schema defaults and current doc JSON
- validateUpdates: Validate on
setState - debug: Verbose logging
- checkStateConsistency: Extra runtime check that the doc-backed base state still matches
toNormalizedJson(doc)after non-ephemeralsetStateupdates - inferOptions:
{ defaultLoroText?: boolean; defaultMovableList?: boolean }for container inference when schema is missing
- Methods:
- getState(): Current state
- setState(updater | partial, options?): Mutate a draft or return a new object. Runs synchronously so downstream logic can immediately read the latest state. When
ephemeralStoreis configured, eligible changes are automatically routed through EphemeralStore. See Ephemeral Patches below.- options:
{ tags?: string | string[]; origin?: string; timestamp?: number; message?: string; finalizeTimeout?: number }— tags surface in subscriber metadata; commit metadata is forwarded to the underlying Loro commit;finalizeTimeoutcontrols the debounce delay before ephemeral values auto-commit.
- options:
- finalizeEphemeralPatches(): Immediately commit pending ephemeral patches to LoroDoc (e.g. on
mouseup). - subscribe((state, metadata) => void): Subscribe; returns unsubscribe
- metadata:
{ source: LORO | MIRROR | EPHEMERAL; tags?: string[] }
- metadata:
- dispose(): Remove all subscriptions
Types: UpdateSource, UpdateMetadata, SetStateOptions.
- Root:
schema({ ...fields }) - Primitives:
schema.String,schema.Number,schema.Boolean,schema.Any,schema.Ignore - Containers (core):
schema.LoroMap({ ...fields })schema.LoroList(itemSchema, idSelector?)schema.LoroText()
- Containers (additional):
schema.LoroMovableList(itemSchema, idSelector)— emits move ops on reorderschema.LoroTree(nodeMapSchema)— hierarchical data (advanced)schema.LoroMapRecord(valueSchema)— dynamic key map with a single value schemaschema.LoroMap({...}).catchall(valueSchema)— mix fixed keys with a catchall value schema
Signatures:
schema.LoroMap(definition, options?)— mirrored state always includes a read-only$cidfield equal to the underlying Loro container id (applies to root/nested maps, list items, and tree nodedatamaps).schema.LoroList(itemSchema, idSelector?: (item) => string, options?)schema.LoroMovableList(itemSchema, idSelector: (item) => string, options?)schema.LoroText(options?)schema.LoroTree(nodeMapSchema, options?)
SchemaOptions for any field: { required?: boolean; defaultValue?: unknown; description?: string; validate?: (value) => boolean | string }.
Any options:
schema.Any({ defaultLoroText?: boolean; defaultMovableList?: boolean })defaultLoroTextdefaults tofalsefor Any when omitted (primitive string), overriding the globalinferOptions.defaultLoroText.defaultMovableListinherits from the global inference options unless specified.
Reserved key $cid:
$cidis injected into mirrored state for allLoroMapschemas; it is never written back to Loro and is ignored by diffs/updates. It’s useful as a stable identifier (e.g.,schema.LoroList(map, x => x.$cid)).
validateSchema(schema, value)— returns{ valid: boolean; errors?: string[] }getDefaultValue(schema)— default value inferred from schema/optionstoNormalizedJson(doc)— JSON matching Mirror’s state shape (e.g., Treemeta->data)
When users drag or scale canvas elements, syncing every intermediate position through LoroDoc creates redundant editing history. Ephemeral patches solve this: pass an EphemeralStore and setState automatically routes temporary changes through it for real-time sync, then commits once to LoroDoc when the operation ends.
Key point: Adding
ephemeralStoretoMirrorOptionschanges howsetStatebehaves. You don't need a separate API — the samesetStatecall is used for both persistent and ephemeral updates.
Without ephemeralStore (default):
setState(updater) → all changes → LoroDoc
With ephemeralStore:
setState(updater) → classify each change:
primitive on existing Map key → EphemeralStore (temporary)
everything else → LoroDoc (permanent)
A single setState call can write to both destinations in one invocation. getState() always returns the composed result (LoroDoc + EphemeralStore overlay), so callers don't need to know where each value lives.
| Change type | Destination | Example |
|---|---|---|
Primitive value (string, number, boolean, null) on an existing key of an existing LoroMap |
EphemeralStore |
s.items[0].x = 100 |
| New Map / new key / container value | LoroDoc |
s.items.push({...}) |
| List / Text / Tree operations | LoroDoc |
s.items.splice(...) |
The rule is intentionally conservative: only simple value-swaps on known keys go to EphemeralStore. Anything structural always goes to LoroDoc.
import { LoroDoc, EphemeralStore } from "loro-crdt";
import { Mirror, schema } from "loro-mirror";
const doc = new LoroDoc();
const eph = new EphemeralStore();
const mirror = new Mirror({
doc,
schema: mySchema,
ephemeralStore: eph, // ← this changes setState behavior
});
// Network sync for ephemeral state (your responsibility)
eph.subscribeLocalUpdates((bytes) => channel.send(bytes));
channel.on("ephemeral", (bytes) => eph.apply(bytes));// During drag — called on every mousemove (~60fps)
// x/y are primitives on existing keys → routed to EphemeralStore.
// LoroDoc stays clean — no editing history for intermediate positions.
mirror.setState(
(s) => {
s.items[i].x = e.clientX;
s.items[i].y = e.clientY;
},
{ finalizeTimeout: 1_000 }, // auto-commit after 1s of inactivity
);
// Mixed: push goes to LoroDoc, x/y go to EphemeralStore (same call)
mirror.setState((s) => {
s.items.push({ x: 0, y: 0, name: "new" }); // → LoroDoc
s.items[0].x = 999; // → EphemeralStore
});
// On mouseup — commit ephemeral values to LoroDoc immediately
mirror.finalizeEphemeralPatches();Ephemeral values are committed to LoroDoc when:
- The debounced
finalizeTimeoutexpires (default: 50 000 ms). The timer resets on eachsetStatecall that produces ephemeral changes, so it only fires after the user stops updating. - You call
finalizeEphemeralPatches()manually (e.g. onmouseup).
On finalize, only values that still match what this peer last wrote are committed. If a remote peer overwrote a value in the EphemeralStore, it is skipped to prevent stale writes.
Because LoroDoc and EphemeralStore are independent, ephemeralStore is fully optional:
- Peers without
ephemeralStoresync normally via LoroDoc. They see final values afterfinalizeEphemeralPatches(), but not intermediate ephemeral changes. - Peers that share an EphemeralStore channel see real-time intermediate updates as well.
Subscribers receive source: "EPHEMERAL" when state changes due to an EphemeralStore update. Use this to distinguish ephemeral changes from permanent LoroDoc changes:
mirror.subscribe((state, { source }) => {
if (source === "EPHEMERAL") {
// Lightweight update — no LoroDoc history created
}
});Trees are for hierarchical data where each node has a data map. The state shape is { id?: string; data: {...}; children: Node[] }.
const node = schema.LoroMap({ name: schema.String({ required: true }) });
const s = schema({ tree: schema.LoroTree(node) });
const mirror = new Mirror({ doc: new LoroDoc(), schema: s });
mirror.setState((st) => {
st.tree.push({ data: { name: "root" }, children: [] });
});Note: If you omit id when creating a node, Loro assigns one; Mirror writes it back on the next state after sync.
Prefer the React helpers in loro-mirror-react for a clean setup.
import { useLoroStore } from "loro-mirror-react";
import { schema } from "loro-mirror";
import { LoroDoc } from "loro-crdt";
const todosSchema = schema({
todos: schema.LoroList(
schema.LoroMap({ text: schema.String() }),
(t) => t.$cid, // list selector uses `$cid` (Loro container id)
),
});
export function App() {
const { state, setState } = useLoroStore({
doc: new LoroDoc(),
schema: todosSchema,
});
return (
<div>
<button
onClick={() =>
setState((s) => {
s.todos.push({ text: "New" });
})
}
>
Add
</button>
<ul>
{state.todos.map((t) => (
<li key={t.$cid /* stable key from Loro container id */}>
{t.text}
</li>
))}
</ul>
</div>
);
}For more React patterns (selectors, actions, provider), see packages/react/README.md or the useLoroContext helpers.
- Use an
idSelectorwhenever list items have stable IDs to get efficient moves instead of delete+insert. setStateaccepts an updater that either mutates a draft or returns a new object — use whichever style you prefer.- Subscriptions receive
{ source: LORO | MIRROR | EPHEMERAL, tags?: string[] }.
MIT