A library for building Yjs collaborative web applications with Mutative.
Mutative is a high-performance immutable data structure library for JavaScript. Y.js is a CRDT library with mutation-based API. mutative-yjs
allows manipulating Y.js data types with the API provided by Mutative.
- 🔄 Bidirectional Sync: Seamlessly sync between Yjs CRDT types and plain JavaScript objects
- 🎯 Immutable Updates: Use Mutative's intuitive draft-based API for state updates
- 📦 Type Safe: Full TypeScript support with type inference
- 🚀 Performance: Efficient patch-based updates with structural sharing
- 🔌 Flexible: Customizable patch application for advanced use cases
- 📡 Reactive: Built-in subscription system for state changes
- ⚡ Explicit Transactions: Updates to Y.js are batched in transactions, you control the boundary
- 🪶 Lightweight: Simple, small codebase with no magic or vendor lock-in
- 🎨 Non-intrusive: Always opt-in by nature (snapshots are just plain objects)
Do:
// any operation supported by mutative
binder.update((state) => {
state.nested[0].key = {
id: 123,
p1: 'a',
p2: ['a', 'b', 'c'],
};
});
Instead of:
Y.transact(state.doc, () => {
const val = new Y.Map();
val.set('id', 123);
val.set('p1', 'a');
const arr = new Y.Array();
arr.push(['a', 'b', 'c']);
val.set('p2', arr);
state.get('nested').get(0).set('key', val);
});
npm install mutative-yjs mutative yjs
# or
yarn add mutative-yjs mutative yjs
# or
pnpm add mutative-yjs mutative yjs
import * as Y from 'yjs';
import { bind } from 'mutative-yjs';
// Create a Yjs document
const doc = new Y.Doc();
const yMap = doc.getMap('data');
// Bind the Yjs data structure
const binder = bind<{ count: number; items: string[] }>(yMap);
// Initialize with data
binder.update((state) => {
state.count = 0;
state.items = ['apple', 'banana'];
});
// Update state using Mutative's draft API
binder.update((state) => {
state.count++;
state.items.push('orange');
});
// Get current snapshot
console.log(binder.get()); // { count: 1, items: ['apple', 'banana', 'orange'] }
// Subscribe to changes
const unsubscribe = binder.subscribe((snapshot) => {
console.log('State updated:', snapshot);
});
// Changes from Yjs are automatically reflected
yMap.set('count', 5);
console.log(binder.get().count); // 5
// Clean up
unsubscribe();
binder.unbind();
import { bind } from 'mutative-yjs'
.- Create a binder:
const binder = bind(doc.getMap("state"))
. - Add subscription to the snapshot:
binder.subscribe(listener)
.- Mutations in Y.js data types will trigger snapshot subscriptions.
- Calling
update(...)
(similar tocreate(...)
in Mutative) will update their corresponding Y.js types and also trigger snapshot subscriptions.
- Call
binder.get()
to get the latest snapshot. - (Optionally) call
binder.unbind()
to release the observer.
Y.Map
binds to plain object {}
, Y.Array
binds to plain array []
, and any level of nested Y.Map
/Y.Array
binds to nested plain JSON object/array respectively.
Y.XmlElement
& Y.Text
have no equivalent to JSON data types, so they are not supported by default. If you want to use them, please use the Y.js top-level type (e.g. doc.getText("xxx")
) directly, or see Customize binding & schema section below.
Binds a Yjs data type to create a binder instance.
Parameters:
source
:Y.Map<any> | Y.Array<any>
- The Yjs data type to bindoptions?
:Options<S>
- Optional configuration
Returns: Binder<S>
- A binder instance with methods to interact with the bound data
Example:
const doc = new Y.Doc();
const yMap = doc.getMap('myData');
const binder = bind<MyDataType>(yMap);
Creates a binder with initial state in one call. This is a convenience function that combines bind()
and initialization.
Parameters:
source
:Y.Map<any> | Y.Array<any>
- The Yjs data type to bindinitialState
:S
- The initial state to setoptions?
:Options<S>
- Optional configuration
Returns: Binder<S>
- A binder instance with the initial state applied
Example:
const doc = new Y.Doc();
const yMap = doc.getMap('myData');
const binder = createBinder(yMap, { count: 0, items: [] });
Returns the current snapshot of the data.
const snapshot = binder.get();
Updates the state using a Mutative draft function. Changes are applied to both the snapshot and the underlying Yjs data structure.
Parameters:
fn
:(draft: S) => void
- A function that receives a draft state to mutate
binder.update((state) => {
state.user.name = 'John';
state.items.push({ id: 1, title: 'New Item' });
});
Subscribes to state changes. The callback is invoked when:
update()
is called- The underlying Yjs data is modified
Parameters:
fn
:(snapshot: S) => void
- Callback function that receives the new snapshotoptions?
:SubscribeOptions
- Optional subscription configurationimmediate?: boolean
- If true, calls the listener immediately with current snapshot
Returns: UnsubscribeFn
- A function to unsubscribe
// Basic subscription
const unsubscribe = binder.subscribe((snapshot) => {
console.log('State changed:', snapshot);
});
// Subscribe with immediate execution
binder.subscribe((snapshot) => {
console.log('Current state:', snapshot);
}, { immediate: true });
// Later...
unsubscribe();
Releases the binder and removes the Yjs observer. Call this when you're done with the binder.
binder.unbind();
Like Mutative, mutative-yjs
provides efficient structural sharing. Unchanged parts of the state maintain the same reference, which is especially beneficial for React re-renders:
const snapshot1 = binder.get();
binder.update((state) => {
state.todos[0].done = true;
});
const snapshot2 = binder.get();
// changed properties have new references
snapshot1.todos !== snapshot2.todos;
snapshot1.todos[0] !== snapshot2.todos[0];
// unchanged properties keep the same reference
snapshot1.todos[1] === snapshot2.todos[1];
snapshot1.todos[2] === snapshot2.todos[2];
You can customize how Mutative patches are applied to Yjs data structures:
const binder = bind<MyDataType>(yMap, {
applyPatch: (target, patch, defaultApplyPatch) => {
// Inspect or modify the patch before applying
console.log('Applying patch:', patch);
// You can conditionally apply patches based on the path
if (patch.path[0] === 'protected') {
// Skip protected fields
return;
}
// Delegate to default behavior
defaultApplyPatch(target, patch);
// Or implement custom logic
// ...
},
});
Configure how Mutative generates patches:
const binder = bind<MyDataType>(yMap, {
patchesOptions: {
pathAsArray: true,
arrayLengthAssignment: true,
},
});
Refer to Mutative patches documentation for more details about patches options.
The library works with both Y.Map
and Y.Array
:
const doc = new Y.Doc();
const yArray = doc.getArray('items');
type Item = { id: string; name: string };
const binder = bind<Item[]>(yArray);
binder.update((items) => {
items.push({ id: '1', name: 'First Item' });
items.push({ id: '2', name: 'Second Item' });
});
// Array operations work as expected
binder.update((items) => {
items[0].name = 'Updated Name';
items.splice(1, 1); // Remove second item
});
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { bind } from 'mutative-yjs';
// Create document and connect to server
const doc = new Y.Doc();
const provider = new WebsocketProvider('ws://localhost:1234', 'room-name', doc);
const yMap = doc.getMap('shared-data');
const binder = bind<AppState>(yMap);
// Subscribe to remote changes
binder.subscribe((snapshot) => {
// Update UI with new state
renderApp(snapshot);
});
// Make local changes
function handleUserAction() {
binder.update((state) => {
state.todos.push({
id: generateId(),
text: 'New todo',
completed: false,
});
});
}
Use useSyncExternalStoreWithSelector
for optimal React integration with selective subscriptions:
import { bind } from 'mutative-yjs';
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector';
import * as Y from 'yjs';
// define state shape
interface State {
todos: Array<{ id: string; text: string; done: boolean }>;
user: { name: string; email: string };
}
const doc = new Y.Doc();
// define store
const binder = bind<State>(doc.getMap('data'));
// define a helper hook
function useMutativeYjs<Selection>(selector: (state: State) => Selection) {
const selection = useSyncExternalStoreWithSelector(
binder.subscribe,
binder.get,
binder.get,
selector
);
return [selection, binder.update] as const;
}
// optionally set initial data
binder.update((state) => {
state.todos = [];
state.user = { name: 'Guest', email: '' };
});
// use in component
function TodoList() {
const [todos, update] = useMutativeYjs((s) => s.todos);
const addTodo = (text: string) => {
update((state) => {
state.todos.push({
id: Math.random().toString(),
text,
done: false,
});
});
};
const toggleTodo = (id: string) => {
update((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) todo.done = !todo.done;
});
};
// will only rerender when 'todos' array changes
return (
<div>
{todos.map((todo) => (
<div key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.text} {todo.done ? '✓' : '○'}
</div>
))}
</div>
);
}
// when done
binder.unbind();
Contributions welcome! Please submit sample code via PR for Vue, Svelte, Angular, or other frameworks.
Applies a plain JavaScript array to a Y.Array.
import { applyJsonArray } from 'mutative-yjs';
import * as Y from 'yjs';
const yArray = new Y.Array();
applyJsonArray(yArray, [1, 2, 3, { nested: 'object' }]);
Applies a plain JavaScript object to a Y.Map.
import { applyJsonObject } from 'mutative-yjs';
import * as Y from 'yjs';
const yMap = new Y.Map();
applyJsonObject(yMap, {
key1: 'value1',
key2: { nested: 'value' },
});
type JSONPrimitive = string | number | boolean | null;
type JSONValue = JSONPrimitive | JSONObject | JSONArray;
type JSONObject = { [member: string]: JSONValue };
interface JSONArray extends Array<JSONValue> {}
type Snapshot = JSONObject | JSONArray;
type UpdateFn<S extends Snapshot> = (draft: S) => void;
type ListenerFn<S extends Snapshot> = (snapshot: S) => void;
type UnsubscribeFn = () => void;
interface Binder<S extends Snapshot> {
unbind: () => void;
get: () => S;
update: (fn: UpdateFn<S>) => void;
subscribe: (fn: ListenerFn<S>) => UnsubscribeFn;
}
interface Options<S extends Snapshot> {
applyPatch?: (
target: Y.Map<any> | Y.Array<any>,
patch: Patch,
applyPatch: (target: Y.Map<any> | Y.Array<any>, patch: Patch) => void
) => void;
patchesOptions?:
| true
| {
pathAsArray?: boolean;
arrayLengthAssignment?: boolean;
};
}
mutative-yjs
creates a bridge between Yjs's CRDT data structures and Mutative's immutable update patterns:
- Initialization: When you bind a Yjs data type, it creates an initial snapshot
- Updates: When you call
update()
, Mutative generates patches describing the changes - Patch Application: Patches are applied to the Yjs data structure, triggering sync
- Event Handling: When Yjs data changes (locally or remotely), events are converted back to snapshot updates
- Structural Sharing: Only modified parts of the snapshot are recreated, maintaining referential equality for unchanged data
- Batch Updates: Multiple changes in a single
update()
call are more efficient than multiple separate calls - Structural Sharing: Unchanged parts of the state maintain referential equality, making React re-renders efficient
- Transactions: Updates are wrapped in Yjs transactions automatically for optimal performance
- Unsubscribe: Always call
unbind()
when done to prevent memory leaks
mutative-yjs
implements smart collaboration semantics to preserve changes from multiple collaborators:
When replacing array elements with objects, the library performs incremental updates instead of delete+insert:
// If both old and new values are objects
binder.update((state) => {
state.items[0] = { ...state.items[0], name: 'Updated' };
});
// → Updates properties in-place, preserving other collaborators' changes
This prevents the "lost update" problem discussed in immer-yjs#1.
The library uses transaction origins to prevent circular updates:
const binder = bind(yMap);
binder.subscribe((snapshot) => {
// Safe: won't cause infinite loop
if (snapshot.count < 10) {
binder.update((state) => {
state.count++;
});
}
});
The library detects and rejects circular object references:
const circular: any = { a: 1 };
circular.self = circular;
binder.update((state) => {
state.data = circular; // ❌ Throws: "Circular reference detected"
});
Check out the test file for comprehensive examples including:
- Basic binding and updates
- Array operations (splice, push, etc.)
- Nested object updates
- Subscription handling
- Custom patch application
- Collaborative scenarios
- Mutative: >= 1.0.0
- Yjs: >= 13.0.0
- TypeScript: >= 4.5
- Node.js: >= 14
Contributions are welcome! Please feel free to submit a Pull Request.
- Mutative - Efficient immutable updates with a mutable API
- Yjs - A CRDT framework for building collaborative applications
This library bridges two powerful tools:
- Yjs for CRDT-based conflict-free collaborative editing
- Mutative for ergonomic and performant immutable state updates
immer-yjs
is inspired by https://github.com/sep2/immer-yjs
.
mutative-yjs
is MIT licensed.