A schema-driven, type-safe wrapper for Loro CRDT that provides natural JavaScript syntax for collaborative document editing. Build local-first applications with intuitive APIs while maintaining full CRDT capabilities.
Loro is a high-performance CRDT (Conflict-free Replicated Data Type) library that enables real-time collaborative editing without conflicts. It's perfect for building local-first applications like collaborative editors, task managers, and (turn-based) multiplayer games.
Working with Loro directly involves somewhat verbose container operations and complex type management. The change package provides:
- Schema-First Design: Define your document structure with type-safe schemas
- Natural Syntax: Write
doc.title.insert(0, "Hello")instead of verbose CRDT operations - Placeholders: Seamlessly blend default values with CRDT state
- Full Type Safety: Complete TypeScript support with compile-time validation
- Transactional Changes: All mutations within a
change()block are atomic - Loro Compatible: Works seamlessly with existing Loro code (
loro(doc)returns the familiarLoroDoc)
npm install @loro-extended/change loro-crdt
# or
pnpm add @loro-extended/change loro-crdtimport { createTypedDoc, Shape, change } from "@loro-extended/change";
// Define your document schema
const schema = Shape.doc({
title: Shape.text().placeholder("My Todo List"),
count: Shape.counter(),
users: Shape.record(
Shape.plain.struct({
name: Shape.plain.string(),
}),
),
});
// Create a typed document
const doc = createTypedDoc(schema);
// Direct mutations - commit immediately (auto-commit mode)
doc.title.insert(0, "📝 Todo");
doc.count.increment(5);
doc.users.set("alice", { name: "Alice" });
// Check existence
if (doc.users.has("alice")) {
console.log("Alice exists!");
}
if ("alice" in doc.users) {
console.log("Also works with 'in' operator!");
}
// Batched mutations - commit together (optional, for performance)
change(doc, (draft) => {
draft.title.insert(0, "Change: ");
draft.count.increment(10);
draft.users.set("bob", { name: "Bob" });
});
// All changes commit as one transaction
// Get JSON snapshot
console.log(doc.toJSON());
// { title: "Change: 📝 Todo", count: 15, users: { alice: { name: "Alice" }, bob: { name: "Bob" } } }Note that this is even more useful in combination with @loro-extended/react (if your app uses React) and @loro-extended/repo for syncing between client/server or among peers.
Define your document structure using Shape builders:
import { Shape } from "@loro-extended/change";
const blogSchema = Shape.doc({
// CRDT containers for collaborative editing
title: Shape.text(), // Collaborative text
viewCount: Shape.counter(), // Collaborative increment/decrement counter
// Lists for ordered data
tags: Shape.list(Shape.plain.string()), // List of strings
// Structs for structured data with fixed keys
metadata: Shape.struct({
author: Shape.plain.string(), // Plain values (POJOs)
publishedAt: Shape.plain.string(), // ISO date string
featured: Shape.plain.boolean(),
}),
// Movable lists for reorderable content
sections: Shape.movableList(
Shape.struct({
heading: Shape.text(), // Collaborative headings
content: Shape.text(), // Collaborative content
order: Shape.plain.number(), // Plain metadata
}),
),
});NOTE: Use Shape.* for collaborative containers and Shape.plain.* for plain values. Only put plain values inside Loro containers - a Loro container inside a plain JS struct or array won't work.
Placeholders provide default values that are merged when CRDT containers are empty, ensuring the entire document remains type-safe even before any data has been written.
In traditional client-server architectures, you typically have a single source of truth that initializes default values. But in CRDTs, multiple peers can start working independently without coordination. This creates a challenge: who initializes the defaults?
Placeholders solve this elegantly:
- No initialization race conditions - Every peer sees the same defaults without needing to coordinate who writes them first
- Zero-cost defaults - Placeholders aren't stored in the CRDT; they're computed on read. This means no wasted storage or sync bandwidth for default values
- Conflict-free - Since placeholders aren't written to the CRDT, there's no possibility of conflicts between peers trying to initialize the same field
- Lazy materialization - Defaults only become "real" CRDT data when a peer explicitly modifies them
// Use .placeholder() to set default values
const blogSchemaWithDefaults = Shape.doc({
title: Shape.text().placeholder("Untitled Document"),
viewCount: Shape.counter(), // defaults to 0
tags: Shape.list(Shape.plain.string()), // defaults to []
metadata: Shape.struct({
author: Shape.plain.string().placeholder("Anonymous"),
publishedAt: Shape.plain.string(), // defaults to ""
featured: Shape.plain.boolean(), // defaults to false
}),
sections: Shape.movableList(
Shape.struct({
heading: Shape.text(),
content: Shape.text(),
order: Shape.plain.number(),
}),
),
});
const doc = createTypedDoc(blogSchemaWithDefaults);
// Initially returns empty state
console.log(doc.toJSON());
// { title: "Untitled Document", viewCount: 0, ... }
// After changes, CRDT values take priority over empty state
change(doc, (draft) => {
draft.title.insert(0, "My Blog Post");
draft.viewCount.increment(10);
});
console.log(doc.toJSON());
// { title: "My Blog Post", viewCount: 10, tags: [], ... }
// ↑ CRDT value ↑ CRDT value ↑ empty state preservedYou can access and write schema properties directly on a TypedDoc. Mutations commit immediately by default:
// Direct mutations - each commits immediately
doc.title.insert(0, "📝");
doc.viewCount.increment(1);
doc.tags.push("typescript");For batched operations (better performance, atomic undo), use change():
change(doc, (draft) => {
// Text operations
draft.title.insert(0, "📝");
draft.title.delete(5, 3);
// Counter operations
draft.viewCount.increment(1);
draft.viewCount.decrement(2);
// List operations
draft.tags.push("typescript");
draft.tags.insert(0, "loro");
draft.tags.delete(1, 1);
// Struct operations (use .set() on PlainValueRef)
draft.metadata.author.set("John Doe");
draft.metadata.featured.set(false);
// Movable list operations
draft.sections.push({
heading: "Introduction",
content: "Welcome to my blog...",
order: 1,
});
draft.sections.move(0, 1); // Reorder sections
});
// All changes are committed atomically as one transaction
console.log(doc.toJSON()); // Updated document state| Use Case | Approach |
|---|---|
| Single mutation | Direct: doc.count.increment(1) |
| Multiple related mutations | Batched: change(doc, d => { ... }) |
| Atomic undo/redo | Batched: change(doc, d => { ... }) |
| Performance-critical bulk updates | Batched: change(doc, d => { ... }) |
| Simple reads + writes | Direct: doc.users.set(...) |
| Encapsulated ref operations | Ref-level: change(ref, d => {...}) |
The change() function also works on individual refs (ListRef, TextRef, TreeRef, etc.), enabling better encapsulation when you want to pass refs around without exposing the entire document:
import { change } from "@loro-extended/change";
// Library code - expose only the ref, not the doc
class StateMachine {
private doc: TypedDoc<...>;
get states(): TreeRef<StateNodeShape> {
return this.doc.states;
}
}
// User code - works with just the ref
function addStates(states: TreeRef<StateNodeShape>) {
change(states, draft => {
const idle = draft.createNode();
idle.data.name.insert(0, "idle");
const running = draft.createNode();
running.data.name.insert(0, "running");
});
}
// Usage
const machine = new StateMachine();
addStates(machine.states); // No access to the underlying doc needed!This pattern is useful for:
- Library APIs: Expose typed refs without leaking document structure
- Component isolation: Pass refs to components that only need partial access
- Testing: Mock or stub individual refs without full document setup
All ref types support change():
// ListRef
change(doc.items, (draft) => {
draft.push("item1");
draft.push("item2");
});
// TextRef
change(doc.title, (draft) => {
draft.insert(0, "Hello ");
draft.insert(6, "World");
});
// CounterRef
change(doc.count, (draft) => {
draft.increment(5);
draft.decrement(2);
});
// StructRef
change(doc.profile, (draft) => {
draft.bio.insert(0, "Hello");
draft.age.increment(1);
});
// RecordRef
change(doc.users, (draft) => {
draft.set("alice", { name: "Alice" });
draft.set("bob", { name: "Bob" });
});
// TreeRef
change(doc.tree, (draft) => {
const node = draft.createNode();
node.data.name.insert(0, "root");
});Nested change() calls are safe - Loro's commit is idempotent:
change(doc.items, (outer) => {
outer.push("from outer");
// Nested change on a different ref - works correctly
change(doc.count, (inner) => {
inner.increment(10);
});
outer.push("still in outer");
});
// All mutations are committedFor type-safe tagged unions (like different message types or presence states), use Shape.plain.discriminatedUnion():
import { Shape } from "@loro-extended/change";
// Define variant shapes - each must have the discriminant key
const ClientPresenceShape = Shape.plain.struct({
type: Shape.plain.string("client"), // Literal type for discrimination
name: Shape.plain.string().placeholder("Anonymous"),
input: Shape.plain.struct({
force: Shape.plain.number(),
angle: Shape.plain.number(),
}),
});
const ServerPresenceShape = Shape.plain.struct({
type: Shape.plain.string("server"), // Literal type for discrimination
cars: Shape.plain.record(
Shape.plain.struct({
x: Shape.plain.number(),
y: Shape.plain.number(),
}),
),
tick: Shape.plain.number(),
});
// Create the discriminated union
const GamePresenceSchema = Shape.plain.discriminatedUnion("type", {
client: ClientPresenceShape,
server: ServerPresenceShape,
});
// Type-safe handling based on discriminant
function handlePresence(presence: Infer<typeof GamePresenceSchema>) {
if (presence.type === "server") {
// TypeScript knows this is ServerPresence
console.log(presence.cars, presence.tick);
} else {
// TypeScript knows this is ClientPresence
console.log(presence.name, presence.input);
}
}Key features:
- The discriminant (e.g.,
"type") determines which variant shape to use - Use
.placeholder()on fields to provide defaults (placeholders are applied automatically) - Works seamlessly with
@loro-extended/repo's presence system - Full TypeScript support for discriminated union types
When integrating with external libraries that manage their own document structure (like loro-prosemirror), you may want typed presence but untyped document content. Use Shape.any() as an escape hatch:
import { Shape } from "@loro-extended/change";
// Fully typed presence with binary cursor data
const CursorPresenceSchema = Shape.plain.struct({
anchor: Shape.plain.bytes().nullable(), // Uint8Array | null
focus: Shape.plain.bytes().nullable(),
user: Shape.plain
.struct({
name: Shape.plain.string(),
color: Shape.plain.string(),
})
.nullable(),
});
// With @loro-extended/repo:
// Shape.any() in a container - one container is untyped
const ProseMirrorDocShape = Shape.doc({
doc: Shape.any(), // loro-prosemirror manages this
metadata: Shape.struct({
// But we can still have typed containers
title: Shape.text(),
}),
});
const handle2 = repo.get(docId, ProseMirrorDocShape, {
presence: CursorPresenceSchema,
});
handle2.doc.toJSON(); // { doc: unknown, metadata: { title: string } }Key features:
Shape.any()creates anAnyContainerShape- type inference producesunknownShape.plain.any()creates anAnyValueShape- type inference produces Loro'sValuetypeShape.plain.bytes()is an alias forShape.plain.uint8Array()for better discoverability- All support
.nullable()for optional values
When to use:
| Scenario | Shape to Use |
|---|---|
| External library manages entire document | repo.get(docId, Shape.any(), { presence: presenceSchema }) |
| External library manages one container | Shape.doc({ doc: Shape.any(), ... }) |
| Flexible metadata in presence | Shape.plain.any() for dynamic values |
| Binary cursor/selection data | Shape.plain.bytes().nullable() for Uint8Array | null |
| Full type safety | Use specific shapes like Shape.struct(), Shape.text() |
Handle complex nested documents with ease:
const complexSchema = Shape.doc({
article: Shape.struct({
title: Shape.text(),
metadata: Shape.struct({
views: Shape.counter(),
author: Shape.struct({
name: Shape.plain.string().placeholder("Anonymous"),
email: Shape.plain.string(),
}),
}),
}),
});
const doc = createTypedDoc(complexSchema);
change(doc, (draft) => {
draft.article.title.insert(0, "Deep Nesting Example");
draft.article.metadata.views.increment(5);
draft.article.metadata.author.name.set("Alice");
draft.article.metadata.author.email.set("alice@example.com");
});For struct containers (fixed-key objects), use direct property access:
const schema = Shape.doc({
settings: Shape.struct({
theme: Shape.plain.string(),
collapsed: Shape.plain.boolean(),
width: Shape.plain.number(),
}),
});
change(doc, (draft) => {
// Set individual values using .set()
draft.settings.theme.set("dark");
draft.settings.collapsed.set(true);
draft.settings.width.set(250);
});Create lists containing CRDT containers for collaborative nested structures:
const collaborativeSchema = Shape.doc({
articles: Shape.list(
Shape.struct({
title: Shape.text(), // Collaborative title
content: Shape.text(), // Collaborative content
tags: Shape.list(Shape.plain.string()), // Collaborative tag list
metadata: Shape.plain.struct({
// Static metadata
authorId: Shape.plain.string(),
publishedAt: Shape.plain.string(),
}),
})
),
});
change(doc, (draft) => {
// Push creates and configures nested containers automatically
draft.articles.push({
title: "Collaborative Article",
content: "This content can be edited by multiple users...",
tags: ["collaboration", "crdt"],
metadata: {
authorId: "user123",
publishedAt: new Date().toISOString(),
},
});
// Later, edit the collaborative parts
draft.articles[0]?.title.insert(0, "✨ ");
draft.articles[0]?.tags.push("real-time");
});The @loro-extended/change package exports a type-safe path selector DSL for building (a subset of) JSONPath expressions with full TypeScript type inference. This is primarily used by handle.subscribe() in @loro-extended/repo for efficient, type-safe subscriptions:
// In @loro-extended/repo, use with Handle.subscribe():
handle.subscribe(
(p) => p.books.$each.title, // Type-safe path selector
(titles, prev) => {
// titles: string[], prev: string[] | undefined
console.log("Titles changed:", titles);
},
);
// DSL constructs:
// p.config.theme - Property access
// p.books.$each - All items in list/record
// p.books.$at(0) - Item at index (supports negative: -1 = last)
// p.books.$first - First item (alias for $at(0))
// p.books.$last - Last item (alias for $at(-1))
// p.users.$key("alice") - Record value by keySee @loro-extended/repo documentation for full details on Handle.subscribe().
Creates a new typed Loro document. This is the recommended way to create documents.
import { createTypedDoc, Shape } from "@loro-extended/change";
const doc = createTypedDoc(schema);
const docFromExisting = createTypedDoc(schema, { doc: existingLoroDoc });Options:
| Option | Type | Default | Description |
|---|---|---|---|
doc |
LoroDoc |
— | Wrap an existing LoroDoc instead of creating a new one |
mergeable |
boolean |
false |
Store containers at root with path-based names for deterministic IDs |
skipInitialize |
boolean |
false |
Skip automatic metadata initialization (for synced documents) |
The primary mutation API. Batches multiple mutations into a single transaction. Works with TypedDoc and all ref types. Returns the target for chaining.
import { change } from "@loro-extended/change";
// Document-level
change(doc, (draft) => {
draft.count.increment(10);
draft.title.update("Hello");
});
// Ref-level
change(doc.items, (draft) => {
draft.push("item1");
});
// With commit message
change(doc, (draft) => {
draft.count.increment(10);
}, { commitMessage: { userId: "alice" } });Options:
| Option | Type | Description |
|---|---|---|
commitMessage |
string | object |
Metadata attached to the commit (objects are JSON-serialized) |
The loro() function returns the native Loro type directly from any TypedDoc or ref. Use it when you need to access the underlying Loro API.
import { loro } from "@loro-extended/change";
// Returns native Loro types directly
const loroDoc = loro(doc); // LoroDoc
const loroText = loro(doc.title); // LoroText
const loroList = loro(doc.items); // LoroList
const loroMap = loro(doc.settings); // LoroMap (for struct or record)
const loroCounter = loro(doc.count); // LoroCounter
const loroTree = loro(doc.states); // LoroTree
// Call native Loro methods directly
loroDoc.frontiers();
loroDoc.peerId;
loroDoc.subscribe(callback);
loroText.length;
loroText.toString();The subscribe() function is the recommended way to listen to document and container changes. It supports three modes:
import { subscribe, loro } from "@loro-extended/change";
// 1. Whole document subscription
const unsubscribe = subscribe(doc, (event) => {
console.log("Document changed:", event.by); // "local" | "import" | "checkout"
});
// 2. Ref-level subscription (specific container)
const unsubscribe = subscribe(doc.title, (event) => {
console.log("Title changed");
});
// 3. Path-selector subscription (type-safe, fine-grained)
const unsubscribe = subscribe(doc, p => p.config.theme, (theme) => {
console.log("Theme changed to:", theme);
});
// For native Loro access, use loro().subscribe() directly:
loro(doc).subscribe(callback); // LoroDoc subscription
loro(doc.title).subscribe(callback); // LoroText subscriptionThe ext() function provides access to loro-extended-specific features that go beyond native Loro. Use it for forking and accessing document metadata.
import { ext } from "@loro-extended/change";
// Document-level features
ext(doc).fork(); // Fork the document
ext(doc).forkAt(frontiers); // Fork at a specific version
ext(doc).shallowForkAt(frontiers); // Fork with shallow snapshot
ext(doc).applyPatch(patch); // Apply JSON Patch operations
ext(doc).docShape; // Access the schema
ext(doc).rawValue; // CRDT state without placeholders
ext(doc).mergeable; // Whether doc uses mergeable storage
ext(doc).initialize(); // Write metadata (if skipInitialize was used)
// Ref-level features
ext(ref).doc; // Get LoroDoc from any ref
ext(list).pushContainer(container); // Push a pre-existing Loro container
ext(list).insertContainer(i, c); // Insert a pre-existing Loro container
ext(struct).setContainer("key", c); // Set a pre-existing Loro container
ext(record).setContainer("key", c); // Set a pre-existing Loro containerListRef / MovableListRef
| Direct Access | Via loro() (native) |
Via ext() (extended) |
|---|---|---|
push(item) |
Native LoroList / LoroMovableList methods |
pushContainer(container) |
insert(index, item) |
insertContainer(index, container) |
|
delete(index, len) |
doc |
|
find(predicate) |
||
filter(predicate) |
||
map(callback) |
||
forEach(callback) |
||
some(predicate) |
||
every(predicate) |
||
slice(start, end) |
||
findIndex(predicate) |
||
length, [index] |
||
toJSON() |
StructRef
| Direct Access | Via loro() (native) |
Via ext() (extended) |
|---|---|---|
obj.property (get) |
Native LoroMap methods |
setContainer(key, container) |
obj.property = value (set) |
doc |
|
Object.keys(obj) |
||
'key' in obj |
||
delete obj.key |
||
toJSON() |
RecordRef (Map-like interface)
| Direct Access | Via loro() (native) |
Via ext() (extended) |
|---|---|---|
get(key) |
Native LoroMap methods |
setContainer(key, container) |
set(key, value) |
doc |
|
delete(key) |
||
has(key) |
||
keys(), values(), entries() |
||
size |
||
replace(values) |
||
merge(values) |
||
clear() |
||
toJSON() |
TextRef
| Direct Access | Via loro() (native) |
Via ext() (extended) |
|---|---|---|
insert(index, content) |
Native LoroText methods |
doc |
delete(index, len) |
||
update(text) |
||
mark(range, key, value) |
||
unmark(range, key) |
||
toDelta(), applyDelta(delta) |
||
toString(), valueOf() |
||
length, toJSON() |
CounterRef
| Direct Access | Via loro() (native) |
Via ext() (extended) |
|---|---|---|
increment(value) |
Native LoroCounter methods |
doc |
decrement(value) |
||
value, valueOf() |
||
toJSON() |
TypedDoc
| Direct Access | Via loro() (native) |
Via ext() (extended) |
|---|---|---|
doc.property (schema access) |
Native LoroDoc methods |
fork(), forkAt() |
toJSON() |
subscribe(callback) |
applyPatch(patch) |
docShape, rawValue |
Use the subscribe() helper to subscribe to container-level changes:
import { subscribe } from "@loro-extended/change";
function TextEditor({ textRef }: { textRef: TextRef }) {
useEffect(() => {
return subscribe(textRef, (event) => {
// Handle text changes
});
}, [textRef]);
return <div>...</div>;
}Use getTransition() to build { before, after } TypedDocs from a
subscription event using the diff overlay (no checkout or fork required):
import { getTransition, loro } from "@loro-extended/change";
// loro(doc) returns a LoroDoc — call subscribe on it directly
const unsubscribe = loro(doc).subscribe((event) => {
if (event.by === "checkout") return;
const { before, after } = getTransition(doc, event);
if (!before.users.has("alice") && after.users.has("alice")) {
console.log("Alice just joined");
}
});Creates a document schema.
const schema = Shape.doc({
field1: Shape.text(),
field2: Shape.counter(),
});
// With mergeable storage for deterministic container IDs
const mergeableSchema = Shape.doc({
players: Shape.record(Shape.struct({ score: Shape.plain.number() })),
}, { mergeable: true });Shape.text()- Collaborative text editingShape.counter()- Collaborative increment/decrement countersShape.list(itemSchema)- Collaborative ordered listsShape.movableList(itemSchema)- Collaborative reorderable listsShape.struct(shape)- Collaborative structs with fixed keys (uses LoroMap internally)Shape.record(valueSchema)- Collaborative key-value maps with dynamic string keysShape.tree(dataShape)- Collaborative hierarchical tree structures with typed node metadataShape.any()- Escape hatch for untyped containers (see Untyped Integration)
Shape.plain.string()- String values (optionally with literal union types)Shape.plain.number()- Number valuesShape.plain.boolean()- Boolean valuesShape.plain.null()- Null valuesShape.plain.undefined()- Undefined valuesShape.plain.uint8Array()- Binary data valuesShape.plain.bytes()- Alias foruint8Array()for better discoverabilityShape.plain.struct(shape)- Struct values with fixed keysShape.plain.record(valueShape)- Object values with dynamic string keysShape.plain.array(itemShape)- Array valuesShape.plain.union(shapes)- Union of value types (e.g.,string | null)Shape.plain.discriminatedUnion(key, variants)- Tagged union types with a discriminant keyShape.plain.any()- Escape hatch for untyped values (see Untyped Integration)
Use .nullable() on value types to create nullable fields with null as the default placeholder:
const schema = Shape.doc({
profile: Shape.struct({
name: Shape.plain.string().placeholder("Anonymous"),
email: Shape.plain.string().nullable(), // string | null, defaults to null
age: Shape.plain.number().nullable(), // number | null, defaults to null
verified: Shape.plain.boolean().nullable(), // boolean | null, defaults to null
tags: Shape.plain.array(Shape.plain.string()).nullable(), // string[] | null
metadata: Shape.plain.record(Shape.plain.string()).nullable(), // Record<string, string> | null
location: Shape.plain
.struct({
// { lat: number, lng: number } | null
lat: Shape.plain.number(),
lng: Shape.plain.number(),
})
.nullable(),
}),
});You can chain .placeholder() after .nullable() to customize the default value:
const schema = Shape.doc({
settings: Shape.struct({
// Nullable string with custom default
nickname: Shape.plain.string().nullable().placeholder("Guest"),
}),
});This is syntactic sugar for the more verbose union pattern:
// These are equivalent:
email: Shape.plain.string().nullable();
email: Shape.plain
.union([Shape.plain.null(), Shape.plain.string()])
.placeholder(null);With the proxy-based API, schema properties are accessed directly on the doc object, and CRDT internals are accessed via loro() and ext().
Access schema properties directly on the doc. Mutations commit immediately (auto-commit mode).
// Read values
const title = doc.title.toString();
const count = doc.count;
// Mutate directly - commits immediately
doc.title.insert(0, "Hello");
doc.count.increment(5);
doc.users.set("alice", { name: "Alice" });
// Check existence
doc.users.has("alice"); // true
"alice" in doc.users; // trueFor batched mutations, use change(doc, fn).
Returns the full plain JavaScript object representation.
const snapshot = doc.toJSON();Returns raw CRDT state without placeholders (empty state overlay).
import { ext } from "@loro-extended/change";
const crdtState = ext(doc).rawValue;Returns the native LoroDoc directly.
import { loro } from "@loro-extended/change";
const loroDoc = loro(doc); // LoroDoc instance
loroDoc.frontiers();
loroDoc.peerId;draft.title.insert(index, content);
draft.title.delete(index, length);
draft.title.update(newContent); // Replace entire content
draft.title.mark(range, key, value); // Add formatting
draft.title.unmark(range, key); // Remove formatting
draft.title.toDelta(); // Get Delta format
draft.title.applyDelta(delta); // Apply Delta operationsdraft.count.increment(value);
draft.count.decrement(value);
const current = draft.count.get();draft.items.push(item);
draft.items.insert(index, item);
draft.items.delete(index, length);
const item = draft.items.get(index);
const array = draft.items.toArray();
const length = draft.items.length;Lists support familiar JavaScript array methods for filtering and finding items:
// Find items (returns mutable draft objects)
const foundItem = draft.todos.find((todo) => todo.completed);
const foundIndex = draft.todos.findIndex((todo) => todo.id === "123");
// Filter items (returns array of mutable draft objects)
const completedTodos = draft.todos.filter((todo) => todo.completed);
const activeTodos = draft.todos.filter((todo) => !todo.completed);
// Transform items (returns plain array, not mutable)
const todoTexts = draft.todos.map((todo) => todo.text);
const todoIds = draft.todos.map((todo) => todo.id);
// Check conditions
const hasCompleted = draft.todos.some((todo) => todo.completed);
const allCompleted = draft.todos.every((todo) => todo.completed);
// Iterate over items
draft.todos.forEach((todo, index) => {
console.log(`Todo ${index}: ${todo.text}`);
});Methods like find() and filter() return mutable draft objects that you can modify directly:
change(doc, (draft) => {
// Find and mutate pattern - very common!
const todo = draft.todos.find((t) => t.id.get() === "123");
if (todo) {
todo.completed.set(true); // ✅ This mutation will persist!
todo.text.set("Updated text"); // ✅ This too!
}
// Filter and modify multiple items
const activeTodos = draft.todos.filter((t) => !t.completed.get());
activeTodos.forEach((todo) => {
todo.priority.set("high"); // ✅ All mutations persist!
});
});This dual interface ensures predicates work with current data (including previous mutations in the same change() block) while returned objects remain mutable.
draft.tasks.push(item);
draft.tasks.insert(index, item);
draft.tasks.set(index, item); // Replace item
draft.tasks.move(fromIndex, toIndex); // Reorder
draft.tasks.delete(index, length);draft.metadata.set(key, value);
draft.metadata.get(key);
draft.metadata.delete(key);
draft.metadata.has(key);
draft.metadata.keys();
draft.metadata.values();
// Access nested values
const value = draft.metadata.get("key");Records support bulk update methods for efficient batch operations:
// Replace entire contents - keys not in the new object are removed
draft.players.replace({
alice: { name: "Alice", score: 100 },
bob: { name: "Bob", score: 50 },
});
// Result: only alice and bob exist, any previous entries are removed
// Merge values - existing keys not in the new object are kept
draft.scores.merge({
alice: 150, // updates alice
charlie: 25, // adds charlie
});
// Result: alice=150, bob=50 (unchanged), charlie=25
// Clear all entries
draft.history.clear();
// Result: empty recordMethod semantics:
| Method | Adds new | Updates existing | Removes absent |
|---|---|---|---|
replace(values) |
✅ | ✅ | ✅ |
merge(values) |
✅ | ✅ | ❌ |
clear() |
❌ | ❌ | ✅ (all) |
These methods batch all operations into a single commit, avoiding multiple subscription notifications.
Trees are hierarchical structures where each node has typed metadata. Perfect for state machines, file systems, org charts, and nested data.
// Define node data shape
const StateNodeDataShape = Shape.struct({
name: Shape.text(),
facts: Shape.record(Shape.plain.any()),
rules: Shape.list(
Shape.plain.struct({
name: Shape.plain.string(),
rego: Shape.plain.string(),
description: Shape.plain.string().nullable(),
}),
),
});
const schema = Shape.doc({
states: Shape.tree(StateNodeDataShape),
});
const doc = createTypedDoc(schema);
change(doc, (draft) => {
// Create root nodes
const idle = draft.states.createNode();
idle.data.name.insert(0, "idle");
const running = draft.states.createNode();
running.data.name.insert(0, "running");
// Create child nodes
const processing = idle.createNode();
processing.data.name.insert(0, "processing");
// Access typed node data
processing.data.rules.push({
name: "validate",
rego: "package validate",
description: null,
});
// Navigate the tree
const parent = processing.parent(); // Returns idle node
const children = idle.children(); // Returns [processing]
// Move nodes between parents
processing.move(running); // Move to different parent
processing.move(); // Move to root (no parent)
// Query the tree
const roots = draft.states.roots(); // All root nodes
const allNodes = draft.states.nodes(); // All nodes (flat)
const node = draft.states.getNodeByID(idle.id); // Find by ID
const exists = draft.states.has(idle.id); // Check existence
// Delete nodes (and all descendants)
draft.states.delete(running);
// Enable fractional indexing for ordering
draft.states.enableFractionalIndex(8);
const index = idle.index(); // Position among siblings
const fractionalIndex = idle.fractionalIndex(); // Fractional index string
});
// Serialize to JSON (nested structure)
const json = doc.toJSON();
// {
// states: [{
// id: "0@123",
// parent: null,
// index: 0,
// fractionalIndex: "80",
// data: { name: "idle", facts: {}, rules: [] },
// children: [...]
// }]
// }
// Get flat array representation
change(doc, (draft) => {
const flatArray = draft.states.toArray();
// [{ id, parent, index, fractionalIndex, data }, ...]
});Tree Node Properties:
node.id- Unique TreeID for the nodenode.data- Typed StructRef for node metadata (access likenode.data.name)node.parent()- Get parent node (or undefined for roots)node.children()- Get child nodes in ordernode.index()- Position among siblingsnode.fractionalIndex()- Fractional index string for orderingnode.isDeleted()- Check if node has been deleted
Tree Node Methods:
node.createNode(initialData?, index?)- Create child nodenode.move(newParent?, index?)- Move to new parent (undefined = root)node.moveAfter(sibling)- Move after siblingnode.moveBefore(sibling)- Move before sibling
TreeRef Methods:
tree.createNode(initialData?)- Create root nodetree.roots()- Get all root nodestree.nodes()- Get all nodes (flat)tree.getNodeByID(id)- Find node by TreeIDtree.has(id)- Check if node existstree.delete(target)- Delete node and descendantstree.enableFractionalIndex(jitter?)- Enable orderingtree.toJSON()- Nested JSON structuretree.toArray()- Flat array representation
You can easily get a plain JavaScript object snapshot of any part of the document using JSON.stringify() or .toJSON(). This works for the entire document, nested containers, and even during loading states (placeholders).
// Get full document snapshot
const snapshot = doc.toJSON();
// Get snapshot of a specific list
const todos = doc.todos.toJSON(); // returns plain array of todos
// Works with nested structures
const metadata = doc.metadata.toJSON(); // returns plain object
// Serialize as JSON
const serializedMetadata = JSON.stringify(doc.metadata); // returns stringNote: JSON.stringify() is recommended for serialization as it handles all data types correctly. .toJSON() is available on all TypedRef objects and proxied placeholders for convenience when you need a direct object snapshot.
Full TypeScript support with compile-time validation:
import { TypedDoc, Shape, type Infer } from "@loro-extended/change";
// Define your desired interface
interface TodoDoc {
title: string;
todos: Array<{ id: string; text: string; done: boolean }>;
}
// Define the schema that matches your interface
const todoSchema = Shape.doc({
title: Shape.text(),
todos: Shape.list(
Shape.plain.struct({
id: Shape.plain.string(),
text: Shape.plain.string(),
done: Shape.plain.boolean(),
}),
),
});
// TypeScript will ensure the schema produces the correct type
const doc = createTypedDoc(todoSchema);
// Mutations are type-safe
change(doc, (draft) => {
draft.title.insert(0, "Hello"); // ✅ Valid - TypeScript knows this is LoroText
draft.todos.push({
// ✅ Valid - TypeScript knows the expected shape
id: "1",
text: "Learn Loro",
done: false,
});
// draft.title.insert(0, 123); // ❌ TypeScript error
// draft.todos.push({ invalid: true }); // ❌ TypeScript error
});
// The result is properly typed as TodoDoc
const result: TodoDoc = doc.toJSON();
// You can also use type assertion to ensure schema compatibility
type SchemaType = Infer<typeof todoSchema>;
const _typeCheck: TodoDoc = {} as SchemaType; // ✅ Will error if types don't matchNote: Use Shape.plain.null() for nullable fields, as Loro treats null and undefined equivalently.
TypedDoc works seamlessly with existing Loro applications:
import { LoroDoc } from "loro-crdt";
import { createTypedDoc, loro } from "@loro-extended/change";
// Wrap existing LoroDoc
const existingDoc = new LoroDoc();
const typedDoc = createTypedDoc(schema, { doc: existingDoc });
// Access underlying LoroDoc
const loroDoc = loro(typedDoc); // returns the LoroDoc directly
// Use with existing Loro APIs
loroDoc.subscribe((event) => {
console.log("Document changed:", event);
});The TypedEphemeral interface in @loro-extended/repo provides type-safe access to ephemeral presence data with placeholder defaults. Define your presence schema and use it with repo.get():
import { Shape } from "@loro-extended/change";
// Define a presence schema with placeholders
const PresenceSchema = Shape.plain.struct({
cursor: Shape.plain.struct({
x: Shape.plain.number(),
y: Shape.plain.number(),
}),
name: Shape.plain.string().placeholder("Anonymous"),
status: Shape.plain.string().placeholder("online"),
});
// Use with @loro-extended/repo
const handle = repo.get("doc-id", DocSchema, { presence: PresenceSchema });
// Read your presence (with placeholder defaults merged in)
console.log(handle.presence.self);
// { cursor: { x: 0, y: 0 }, name: "Anonymous", status: "online" }
// Set presence values
handle.presence.setSelf({ cursor: { x: 100, y: 200 }, name: "Alice" });
// Read other peers' presence
for (const [peerId, presence] of handle.presence.peers) {
console.log(`${peerId}: ${presence.name}`);
}
// Subscribe to presence changes
handle.presence.subscribe(({ key, value, source }) => {
console.log(`Peer ${key} updated:`, value);
});See @loro-extended/repo documentation for full details on the TypedEphemeral interface.
- All changes within a
change()call are batched into a single transaction - Empty state overlay is computed on-demand, not stored
- Container creation is lazy - containers are only created when accessed
- Type validation occurs at development time, not runtime
This package is part of the loro-extended ecosystem. Contributions welcome!
- Build:
pnpm build - Test:
pnpm test - Lint:
pnpm check
MIT