Loro Mirror: Make UI State Collaborative by Mirroring to CRDTs

TL;DR. Loro Mirror keeps a typed, immutable app‑state view in sync with a Loro CRDT document. Local setState edits become granular CRDT operations; incoming CRDT events update your state. You keep familiar React patterns and gain collaboration, offline edits, and history.
CRDT: A Conflict‑free Replicated Data Type lets multiple peers edit concurrently and still converge without central coordination.
Local‑first: Data is usable offline and synced later; the device is the primary source of truth.
Overview
Loro is a CRDT library for local‑first apps. It supports rich containers—Text, Map, List/MovableList, MovableTree—with versioning, time‑travel, and compact updates/snapshots.
Though CRDTs ensure CRDTs states converge, apps still need glue code to map between CRDT documents and UI state to ensure their consistency. It’s not an easy task.
Loro Mirror addresses this boundary. You declare a schema once. Mirror maintains an immutable app‑state view and handles both directions:
- Event → state. Loro events update your state.
- State → CRDT.
setStatediffs become container‑level CRDT ops (insert / delete / move / text edits).
For an update, if k items change and each changed item affects m of its immediate fields, time complexity is ≈ O(k·m). (k = number of changed items; m = average number of changed immediate fields per changed item.) This is similar to React’s render complexity.
Why this exists
Without Mirror, projects that uses Loro need to:
- Map CRDTs states to UI states
- Diff UI edits and translate them to CRDT operations
- Subscribe to CRDT events and patch UI state
This code is repetitive and easy to get wrong. Mirror centralizes it behind a declarative schema.
What Mirror provides
- Declarative schema. Describe UI state in terms of Loro containers; Mirror maintains an immutable view.
- Typed and framework‑agnostic. Works in plain TypeScript, React (via
loro-mirror-react) or any other UI framework that supports immutable states. - Fine‑grained diffs. Generates ops such as item moves in
MovableListand character deltas inText.
How to use
- Define a schema that describes your app state
- Create a
LoroDocand a Mirror store; provideschema - Update via
setState. Subscribe for changes if needed. - Sync across peers using Loro updates; Mirror applies remote delta back to your app state automatically.
Basic Example
import { } from "loro-crdt";
import { , , } from "loro-mirror";
// 1) Declare state shape – a MovableList of todos with stable Container ID `$cid`
type = "todo" | "inProgress" | "done";
const = ({
: .(
.({
: .(),
: .<>(),
}),
// $cid is the container ID of LoroMap assigned by Loro
() => .,
),
});
// 2) Create a Loro document and a Mirror store
const = new ();
const = new ({
,
: ,
// InitialState will not be written into LoroDoc
: { : [] },
});
// 3) Subscribe (optional) – know whether updates came from local or remote
const = .((, { , }) => {
if ( === .) {
.("Remote update", { , });
} else {
.("Local update", { , });
}
// You can use `state` to render directly, it's a new immutable object that shares
// the unchanged fields with the old state
();
});
// 4) Either draft‑mutate or return a new state
// Draft‑style (mutate a draft)
.(() => {
..({ : "Draft add", : "todo" });
});
// Immutable return (construct a new object)
.(() => ({
...,
: [...., { : "Immutable add", : "todo" }],
}));
// 5) Sync across peers with Loro updates (transport‑agnostic)
// Example: two docs in memory – in real apps, send `bytes` over WS/HTTP/WebRTC
const = new ();
.(.({ : "snapshot" }));
// Wire realtime sync (local updates → remote import)
const = .(() => {
.();
});
// Any `store.setState(...)` on `doc` now appears in `other` as wellReact Example
import React, { } from "react";
import { } from "loro-crdt";
import { } from "loro-mirror";
import { } from "loro-mirror-react";
type = "todo" | "inProgress" | "done";
const = ({
: .(
.({
: .(),
: .<>(),
}),
() => .,
),
});
export function () {
const = (() => new (), []);
const { , } = ({
,
: ,
: { : [] },
});
function (: string) {
(() => {
..({ , : "todo" });
});
}
return (
<>
< ={() => ("Write blog")}>Add</>
<>
{..(() => (
< ={.}>
<
={.}
={() =>
(() => {
const = ..(() => . === .);
// Text delta will be calculated automatically
if ( !== -1) .[]. = ..;
})
}
/>
<
={.}
={() =>
(() => {
const = ..(() => . === .);
if ( !== -1)
.[]. = .. as ;
})
}
>
< ="todo">Todo</>
< ="inProgress">In Progress</>
< ="done">Done</>
</>
</>
))}
</>
</>
);
}Undo/Redo
import { UndoManageker } from "loro-crdt";
// Inside the same component, after creating `doc`:
const undo = useMemo(() => new UndoManager(doc), [doc]);
// Add controls anywhere in your UI:
<div>
<button onClick={() => undo.undo()}>Undo</button>
<button onClick={() => undo.redo()}>Redo</button>
{/* UndoManager only reverts your local edits; remote edits stay. */}
{/* See docs: <https://loro.dev/docs/advanced/undo> */}
{/* For full time travel, see: <https://loro.dev/docs/tutorial/time_travel> */}
</div>;What you get
- Type-safe, framework-agnostic state
- Each mutation becomes a minimal change-set (CRDT delta)—no manual diffing
- Fine-grained updates to subscribers for fast, predictable renders
- Built-in history and time travel
- Offline-first sync via updates or snapshots with deterministic conflict resolution over any transport (HTTP, WebSocket, P2P)
- Collaborative undo/redo across clients
We built a example PWA app here https://todo.loro.dev . It’s open source at https://github.com/loro-dev/loro-todo. It’s collaborative and account-free. The data will be persisted locally in IndexedDB and saved in the cloud for 7 days. You can share your todo list with others by just sharing the unique URL. In the codebase, only a tiny portion of the code is about Loro thanks to the help of loro-mirror.
Where we’re going
Because Mirror owns the bidirectional mapping between application state and the Loro document, we can move value up the stack while lowering integration cost. For example:
- Text. Many interfaces render by lines, yet LoroText’s low‑level API is index‑based. Teams typically re‑implement line segmentation and map edits back to lines by hand. With Mirror in the middle, it becomes feasible to surface optional line‑aware events on top of LoroText so the UI receives stable, line‑based diffs without custom conversion—while retaining the underlying CRDT guarantees.
- Tree. LoroTree CRDT already ensures correct concurrent moves, but developers still translate tree operations into application‑state patches. Mirror carries first‑class mappings from tree events into your state shape, so consumers can work with natural “insert/move/delete node” updates.
- Ephemeral patches. We’ll add
setStateWithEphemeralPatchso Mirror can stream temporary drag or scale interactions through anEphemeralStore, letting collaborators see live previews while the persisted history stays clean and deduplicated once the change finalizes.
By using loro-mirror to bridge CRDTs and application state consistency, and by expressing schemas declaratively, we can let AI help developers get more done correctly. This makes Loro not only suitable for professional creative tools with real-time collaboration, but also for enabling people to build practical mini-tools for themselves and their communities.
If this work helps you build collaborative, local‑first experiences, we’d be grateful for your sponsorship. You can support us via GitHub Sponsors.