loro-mirror
v2.2.0
Published
Type-safe state management synchronized with Loro CRDT via a declarative schema and bidirectional mirroring.
Maintainers
Readme
Loro Mirror
Quick Start
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"
});Installation
npm install loro-mirror loro-crdtAPI Reference
Core 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.
Mirror
- 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; mergeableMapChildContainers?: boolean }for container inference and Map child-container creation
- 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.
Schema Builder
- 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 }.
Map-only option: { mergeableMapChildContainers?: boolean } on schema.LoroMap or schema.LoroMapRecord.
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.
Mergeable Map child containers:
By default, child containers are created with LoroMap.setContainer. If two peers concurrently create a child container under the same Map key (e.g. both first-write records.note), each peer's child gets a distinct container ID. Because the Map slot is last-writer-wins, one peer's child — and everything written into it — is silently dropped after sync.
Mergeable containers fix this: the child's identity is derived from its logical position (parent container id + key + type) instead of the creating operation, so every peer's ensureMergeable* call resolves to the same CRDT object and their content merges. See https://loro.dev/blog/mergeable-containers for the full model.
Enable it on the parent Map when multiple peers may create the same child for the first time concurrently:
const appSchema = schema({
records: schema.LoroMapRecord(
schema.LoroMap({ entries: schema.LoroList(schema.String()) }),
{ mergeableMapChildContainers: true }, // ← opt in on the parent map
),
});
// docA.setState({ records: { note: { entries: ["A"] } } }) and
// docB.setState({ records: { note: { entries: ["B"] } } }) now converge to one
// `note` map whose `entries` contains both "A" and "B" after sync — instead of
// one peer's `note` clobbering the other's.- Prefer configuring the parent Map:
schema.LoroMap(..., { mergeableMapChildContainers: true })orschema.LoroMapRecord(..., { mergeableMapChildContainers: true }). inferOptions.mergeableMapChildContainersremains the schema-less/global fallback and defaults tofalsefor backward-compatible document format andLoroMap.setContainersemantics.- When enabled for a parent Map, new direct child containers under that
LoroMap's keys are created with Loro'sensureMergeableMap/List/MovableList/Text/TreeAPIs, so concurrent first creation at the same parent/key/type shares one logical child container. - Existing same-type child containers are reused as-is, including regular
setContainerchildren from old data. - All collaborating clients must use
loro-crdt >= 1.13.3to interpret mergeable child markers. Older clients preserve the data as ordinary binary values but do not expose the mergeable child semantics. - Concurrent first writes merge CRDT content rather than acting like LWW replacement. If two peers insert the same text or list item while offline, both writes can be visible after sync.
- Avoid very deep chains of mergeable child maps because mergeable container IDs encode the logical path and grow with depth.
- List items and tree nodes keep the existing
insertContainer/node-data behavior because they are not identified by a stable Map key.
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)).
Validators & Helpers
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)
Ephemeral Patches
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.
How setState changes with ephemeralStore
Without ephemeralStore (default):
setState(updater) → all changes → LoroDocWith 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.
Routing rules
| 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.
Setup
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));Usage
// 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();Finalization
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.
Cross-peer compatibility
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.
Subscriber source
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
}
});Advanced: Trees
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.
Tiny React Example
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.
Notes & Tips
- 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[] }.
License
MIT
