A TypeScript state management library that syncs application state with loro-crdt.
- 🔄 Bidirectional Sync: Seamlessly sync between application state and Loro CRDT
- 📊 Schema Validation: Type-safe schema system for validating state
- 🧩 Modular Design: Core package for state management, React package for React integration
- 🔍 Selective Updates: Subscribe to specific parts of your state
- 🛠️ Developer Friendly: Familiar API inspired by popular state management libraries
- 📱 React Integration: Hooks and context providers for React applications
loro-mirror
: Core state management functionalityloro-mirror-react
: React integration with hooks and contextloro-mirror-jotai
: Jotai integration
npm install loro-mirror loro-crdt
# or
yarn add loro-mirror loro-crdt
# or
pnpm add loro-mirror loro-crdt
loro-mirror-react
and loro-mirror-jotai
are optional.
import { LoroDoc } from "loro-crdt";
import { Mirror, schema } from "loro-mirror";
// Define your schema
const todoSchema = schema({
todos: schema.LoroList(
schema.LoroMap({
text: schema.String(),
completed: schema.Boolean({ defaultValue: false }),
}),
// Use `$cid` (reuses Loro container id; explained later)
(t) => t.$cid,
),
});
// Create a Loro document
const doc = new LoroDoc();
// Create a mirror
const mirror = new Mirror({
doc,
schema: todoSchema,
initialState: { todos: [] },
});
// Update the state (immutable update)
mirror.setState((s) => ({
...s,
todos: [
...s.todos,
{
text: "Learn Loro Mirror",
completed: false,
},
],
}));
// Or: draft-style updates (mutate a draft)
mirror.setState((state) => {
state.todos.push({
text: "Learn Loro Mirror",
completed: false,
});
// `$cid` is injected automatically for LoroMap items
// and reuses the underlying Loro container id (explained later)
});
// Subscribe to state changes
mirror.subscribe((state) => {
console.log("State updated:", state);
});
Loro Mirror provides a declarative schema system that enables:
- Type Inference: Automatically infer TypeScript types for your application state from the schema
- Runtime Validation: Validate data structure and types during
setState
operations or synchronization - Default Value Generation: Generate sensible default values based on the schema definition
- Root Schema: The root object defined via
schema({...})
, containing only Loro container types (Map/List/Text/MovableList). - Field Schema: A combination of primitive types (string, number, boolean), ignore fields, and Loro containers.
- Schema Options (
SchemaOptions
):required?: boolean
: Whether the field is required (default:true
).defaultValue?: unknown
: Default value for the field.description?: string
: Description of the field.validate?: (value) => boolean | string
: Custom validation function. Returntrue
for valid values, or a string as error message for invalid ones.
-
Primitive Types:
schema.String<T extends string = string>(options?)
- String type with optional generic constraintschema.Number(options?)
- Number typeschema.Boolean(options?)
- Boolean typeschema.Ignore(options?)
- Field that won't sync with Loro, useful for local computed fields
-
Container Types:
schema.LoroMap(definition, options?)
- Object container that can nest arbitrary field schemas- Supports dynamic key-value definition with
catchall
:schema.LoroMap({...}).catchall(valueSchema)
- Always injects a read-only
$cid
field in mirrored state equal to the underlying Loro container id. Applies uniformly to root maps, nested maps, list items, and tree nodedata
maps.
- Supports dynamic key-value definition with
schema.LoroMapRecord(valueSchema, options?)
- Equivalent toLoroMap({}).catchall(valueSchema)
for homogeneous maps- Entries’ mirrored state always include
$cid
.
- Entries’ mirrored state always include
schema.LoroList(itemSchema, idSelector?, options?)
- Ordered list container- Providing an
idSelector
(e.g.,(item) => item.id
) enables minimal add/remove/update/move diffs
- Providing an
schema.LoroMovableList(itemSchema, idSelector, options?)
- List with native move operations, requires anidSelector
schema.LoroText(options?)
- Collaborative text editing container
Automatically derive strongly-typed state from your schema:
import { schema } from "loro-mirror";
type UserId = string & { __brand: "userId" };
const appSchema = schema({
user: schema.LoroMap({
name: schema.String(),
age: schema.Number({ required: false }),
}),
tags: schema.LoroList(schema.String()),
});
// Inferred state type:
// type AppState = {
// user: { $cid: string; name: string; age: number | undefined };
// tags: string[];
// }
type AppState = InferType<typeof appSchema>;
Note: If you need optional custom string types with generics (e.g.,
{ status?: Status }
), explicitly define them asschema.String<Status>({ required: false })
.
For LoroMap
with dynamic key-value pairs:
const mapWithCatchall = schema
.LoroMap({ fixed: schema.Number() })
.catchall(schema.String());
// Type: { fixed: number } & { [k: string]: string }
const record = schema.LoroMapRecord(schema.Boolean());
// Type: { [k: string]: boolean }
When a field has required: false
, the corresponding type becomes optional (union with undefined
).
LoroMap and LoroMapRecord inferred types always include a $cid: string
field. For example:
const user = schema.LoroMap({ name: schema.String() });
// InferType<typeof user> => { name: string; $cid: string }
// In lists, `$cid` is handy as a stable idSelector:
const users = schema.LoroList(user, (x) => x.$cid);
- Explicitly specified
defaultValue
takes the highest precedence. - Built-in defaults for fields without
defaultValue
andrequired: true
:- String / LoroText →
""
- Number →
0
- Boolean →
false
- LoroList →
[]
- LoroMap / Root → Recursively aggregated defaults from child fields
- String / LoroText →
Mirror
validates against the schema when validateUpdates
is enabled (default: true
). You can also validate directly:
import { validateSchema } from "loro-mirror";
const result = validateSchema(appSchema, {
user: { id: "u1", name: "Alice", age: 18 },
tags: ["a", "b"],
});
// result = { valid: boolean; errors?: string[] }
LoroList(item, idSelector?)
: Providing anidSelector
enables more stable add/remove/update/move diffs; otherwise uses index-based comparison.LoroMovableList(item, idSelector)
: Native move operations (preserves element identity), ideal for drag-and-drop scenarios.
const todoSchema = schema({
todos: schema.LoroMovableList(
schema.LoroMap({
text: schema.String(),
completed: schema.Boolean({ defaultValue: false }),
}),
(t) => t.$cid, // stable id from Loro container id ($cid)
),
});
- Fields defined with
schema.Ignore()
won't sync with Loro, commonly used for derived/cached fields. Runtime validation always passes for these fields.
$cid
is a reserved, read-only field injected into mirrored state for allLoroMap
schemas. It equals the underlying Loro container id, is never written back to Loro, and is ignored by diffs and updates. Use it as a stable identifier where helpful (e.g., listidSelector
).
import React, { useMemo, useState } from "react";
import { LoroDoc } from "loro-crdt";
import { schema } from "loro-mirror";
import { createLoroContext } from "loro-mirror-react";
// Define your schema (use `$cid` from maps)
const todoSchema = schema({
todos: schema.LoroList(
schema.LoroMap({
text: schema.String({ required: true }),
completed: schema.Boolean({ defaultValue: false }),
}),
(t) => t.$cid, // uses Loro container id; see "$cid" section below
),
});
// Create a context
const { LoroProvider, useLoroState, useLoroSelector, useLoroAction } =
createLoroContext(todoSchema);
// Root component
function App() {
const doc = useMemo(() => new LoroDoc(), []);
return (
<LoroProvider doc={doc} initialState={{ todos: [] }}>
<TodoList />
<AddTodoForm />
</LoroProvider>
);
}
// Todo list component
function TodoList() {
const todos = useLoroSelector((state) => state.todos);
const toggleTodo = useLoroAction((s, cid: string) => {
const i = s.todos.findIndex((t) => t.$cid === cid);
if (i !== -1) s.todos[i].completed = !s.todos[i].completed;
}, []);
return (
<ul>
{todos.map((todo) => (
<li key={todo.$cid}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.$cid)} // `$cid` is the Loro container id
/>
<span>{todo.text}</span>
</li>
))}
</ul>
);
}
// Add todo form component
function AddTodoForm() {
const [text, setText] = useState("");
const addTodo = useLoroAction(
(state) => {
state.todos.push({
text: text.trim(),
completed: false,
});
},
[text],
);
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
addTodo();
setText("");
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add Todo</button>
</form>
);
}
For detailed documentation, see the README files in each package:
Loro Mirror uses a typed schema to map your app state to Loro containers. Common schema constructors:
schema.String(options?)
: stringschema.Number(options?)
: numberschema.Boolean(options?)
: booleanschema.Ignore(options?)
: exclude from sync (app-only)schema.LoroText(options?)
: rich text (LoroText
)schema.LoroMap(definition, options?)
: object (LoroMap
)schema.LoroList(itemSchema, idSelector?, options?)
: list (LoroList
)schema.LoroMovableList(itemSchema, idSelector, options?)
: movable list that emits move ops (requiresidSelector
)schema.LoroTree(nodeSchema, options?)
: hierarchical tree (LoroTree
) with per-nodedata
map
Tree nodes have the shape { id?: string; data: T; children: Node<T>[] }
. Define a tree by passing a node LoroMap
schema:
import { schema } from "loro-mirror";
const node = schema.LoroMap({ title: schema.String({ required: true }) });
const mySchema = schema({ outline: schema.LoroTree(node) });
-
new Mirror(options)
: Creates a bidirectional sync between app state and aLoroDoc
.doc
:LoroDoc
– required Loro document instance.schema
: root schema – optional but recommended for strong typing and validation.initialState
: partial state – merged with schema defaults and current doc JSON.validateUpdates
: boolean (defaulttrue
) – validate new state against schema.throwOnValidationError
: boolean (defaultfalse
) – throw on invalid updates.debug
: boolean (defaultfalse
) – log diffs and applied changes.checkStateConsistency
: boolean (defaultfalse
) – after eachsetState
, assert the in-memory state equals the normalizedLoroDoc
snapshot.inferOptions
:{ defaultLoroText?: boolean; defaultMovableList?: boolean }
– influence container-type inference when inserting containers from plain values.
-
getState(): State
: Returns the current in-memory state view. -
setState(updater, options?)
: Update state and sync to Loro. Runs synchronously.updater
: either a partial object to shallow-merge or a function that may mutate a draft (Immer-style) or return a new state object.options
:{ tags?: string | string[] }
– arbitrary tags attached to this update; delivered to subscribers in metadata.
-
subscribe(callback): () => void
: Subscribe to state changes.callback
receives(state, metadata)
wheremetadata
includes:direction
:SyncDirection
–FROM_LORO
when changes came from the doc,TO_LORO
when produced locally,BIDIRECTIONAL
for manual/initial syncs.tags
:string[] | undefined
– tags provided viasetState
.
-
dispose()
: Unsubscribe internal listeners and clear subscribers. -
checkStateConsistency()
: Manually trigger the consistency assertion described above. -
getContainerIds()
: Returns the set of registered Loro container IDs (advanced debugging aid).
- Lists and IDs: If your list schema provides an
idSelector
, list updates use minimal add/remove/update/move operations; otherwise index-based diffs are applied. - Container inference: When schema is missing/ambiguous for a field, the mirror infers container types from values.
inferOptions.defaultLoroText
makes strings becomeLoroText
;inferOptions.defaultMovableList
makes arrays becomeLoroMovableList
. - Consistency checks: Enabling
checkStateConsistency
(or calling the method directly) is useful while developing or writing tests; it throws if Mirror state diverges from the normalized document snapshot.
SyncDirection
:FROM_LORO
– applied due to incomingLoroDoc
changesTO_LORO
– applied due to localsetState
BIDIRECTIONAL
– initial/manual sync context
UpdateMetadata
:{ direction: SyncDirection; tags?: string[] }
SetStateOptions
:{ tags?: string | string[] }
import { LoroDoc } from "loro-crdt";
import { Mirror, schema, SyncDirection } from "loro-mirror";
const todoSchema = schema({
todos: schema.LoroList(
schema.LoroMap({
text: schema.String({ required: true }),
completed: schema.Boolean({ defaultValue: false }),
}),
(t) => t.$cid, // stable id from Loro container id ($cid)
),
});
const doc = new LoroDoc();
const mirror = new Mirror({ doc, schema: todoSchema, validateUpdates: true });
// Subscribe with metadata
const unsubscribe = mirror.subscribe((state, { direction, tags }) => {
if (direction === SyncDirection.FROM_LORO) {
console.log("Remote update", tags);
} else {
console.log("Local update", tags);
}
});
// Update with draft mutation + tags
mirror.setState(
(s) => {
s.todos.push({
text: "Write docs",
completed: false,
});
},
{ tags: ["ui:add"] },
);
### How `$cid` Works
- Every Loro container has a stable container ID provided by Loro (e.g., a map’s `container.id`).
- For any `schema.LoroMap(...)`, Mirror injects a read-only `$cid` field into the mirrored state that equals the underlying Loro container ID.
- `$cid` lives only in the app state and is never written back to the document. Mirror uses it for efficient diffs; you can use it as a stable list selector: `schema.LoroList(item, (x) => x.$cid)`.
// Cleanup
unsubscribe();
mirror.dispose();
MIT