- Notifications
You must be signed in to change notification settings - Fork1
A library for building Yjs collaborative web applications with Mutative
License
mutativejs/mutative-yjs
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
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 mutativebinder.update((state)=>{state.nested[0].key={id:123,p1:'a',p2:['a','b','c'],};});
Instead of:
Y.transact(state.doc,()=>{constval=newY.Map();val.set('id',123);val.set('p1','a');constarr=newY.Array();arr.push(['a','b','c']);val.set('p2',arr);state.get('nested').get(0).set('key',val);});
npm install mutative-yjs mutative yjs# oryarn add mutative-yjs mutative yjs# orpnpm add mutative-yjs mutative yjs
import*asYfrom'yjs';import{bind}from'mutative-yjs';// Create a Yjs documentconstdoc=newY.Doc();constyMap=doc.getMap('data');// Bind the Yjs data structureconstbinder=bind<{count:number;items:string[]}>(yMap);// Initialize with databinder.update((state)=>{state.count=0;state.items=['apple','banana'];});// Update state using Mutative's draft APIbinder.update((state)=>{state.count++;state.items.push('orange');});// Get current snapshotconsole.log(binder.get());// { count: 1, items: ['apple', 'banana', 'orange'] }// Subscribe to changesconstunsubscribe=binder.subscribe((snapshot)=>{console.log('State updated:',snapshot);});// Changes from Yjs are automatically reflectedyMap.set('count',5);console.log(binder.get().count);// 5// Clean upunsubscribe();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 nestedY.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 seeCustomize 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:
constdoc=newY.Doc();constyMap=doc.getMap('myData');constbinder=bind<MyDataType>(yMap);
Creates a binder with initial state in one call. This is a convenience function that combinesbind() 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:
constdoc=newY.Doc();constyMap=doc.getMap('myData');constbinder=createBinder(yMap,{count:0,items:[]});
Returns the current snapshot of the data.
constsnapshot=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 subscriptionconstunsubscribe=binder.subscribe((snapshot)=>{console.log('State changed:',snapshot);});// Subscribe with immediate executionbinder.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:
constsnapshot1=binder.get();binder.update((state)=>{state.todos[0].done=true;});constsnapshot2=binder.get();// changed properties have new referencessnapshot1.todos!==snapshot2.todos;snapshot1.todos[0]!==snapshot2.todos[0];// unchanged properties keep the same referencesnapshot1.todos[1]===snapshot2.todos[1];snapshot1.todos[2]===snapshot2.todos[2];
You can customize how Mutative patches are applied to Yjs data structures:
constbinder=bind<MyDataType>(yMap,{applyPatch:(target,patch,defaultApplyPatch)=>{// Inspect or modify the patch before applyingconsole.log('Applying patch:',patch);// You can conditionally apply patches based on the pathif(patch.path[0]==='protected'){// Skip protected fieldsreturn;}// Delegate to default behaviordefaultApplyPatch(target,patch);// Or implement custom logic// ...},});
Configure how Mutative generates patches:
constbinder=bind<MyDataType>(yMap,{patchesOptions:{pathAsArray:true,arrayLengthAssignment:true,},});
Refer toMutative patches documentation for more details about patches options.
The library works with bothY.Map andY.Array:
constdoc=newY.Doc();constyArray=doc.getArray('items');typeItem={id:string;name:string};constbinder=bind<Item[]>(yArray);binder.update((items)=>{items.push({id:'1',name:'First Item'});items.push({id:'2',name:'Second Item'});});// Array operations work as expectedbinder.update((items)=>{items[0].name='Updated Name';items.splice(1,1);// Remove second item});
import*asYfrom'yjs';import{WebsocketProvider}from'y-websocket';import{bind}from'mutative-yjs';// Create document and connect to serverconstdoc=newY.Doc();constprovider=newWebsocketProvider('ws://localhost:1234','room-name',doc);constyMap=doc.getMap('shared-data');constbinder=bind<AppState>(yMap);// Subscribe to remote changesbinder.subscribe((snapshot)=>{// Update UI with new staterenderApp(snapshot);});// Make local changesfunctionhandleUserAction(){binder.update((state)=>{state.todos.push({id:generateId(),text:'New todo',completed:false,});});}
UseuseSyncExternalStoreWithSelector for optimal React integration with selective subscriptions:
import{bind}from'mutative-yjs';import{useSyncExternalStoreWithSelector}from'use-sync-external-store/with-selector';import*asYfrom'yjs';// define state shapeinterfaceState{todos:Array<{id:string;text:string;done:boolean}>;user:{name:string;email:string};}constdoc=newY.Doc();// define storeconstbinder=bind<State>(doc.getMap('data'));// define a helper hookfunctionuseMutativeYjs<Selection>(selector:(state:State)=>Selection){constselection=useSyncExternalStoreWithSelector(binder.subscribe,binder.get,binder.get,selector);return[selection,binder.update]asconst;}// optionally set initial databinder.update((state)=>{state.todos=[];state.user={name:'Guest',email:''};});// use in componentfunctionTodoList(){const[todos,update]=useMutativeYjs((s)=>s.todos);constaddTodo=(text:string)=>{update((state)=>{state.todos.push({id:Math.random().toString(), text,done:false,});});};consttoggleTodo=(id:string)=>{update((state)=>{consttodo=state.todos.find((t)=>t.id===id);if(todo)todo.done=!todo.done;});};// will only rerender when 'todos' array changesreturn(<div>{todos.map((todo)=>(<divkey={todo.id}onClick={()=>toggleTodo(todo.id)}>{todo.text}{todo.done ?'✓' :'○'}</div>))}</div>);}// when donebinder.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*asYfrom'yjs';constyArray=newY.Array();applyJsonArray(yArray,[1,2,3,{nested:'object'}]);
Applies a plain JavaScript object to a Y.Map.
import{applyJsonObject}from'mutative-yjs';import*asYfrom'yjs';constyMap=newY.Map();applyJsonObject(yMap,{key1:'value1',key2:{nested:'value'},});
typeJSONPrimitive=string|number|boolean|null;typeJSONValue=JSONPrimitive|JSONObject|JSONArray;typeJSONObject={[member:string]:JSONValue};interfaceJSONArrayextendsArray<JSONValue>{}typeSnapshot=JSONObject|JSONArray;typeUpdateFn<SextendsSnapshot>=(draft:S)=>void;typeListenerFn<SextendsSnapshot>=(snapshot:S)=>void;typeUnsubscribeFn=()=>void;interfaceBinder<SextendsSnapshot>{unbind:()=>void;get:()=>S;update:(fn:UpdateFn<S>)=>void;subscribe:(fn:ListenerFn<S>)=>UnsubscribeFn;}interfaceOptions<SextendsSnapshot>{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 performsincremental updates instead of delete+insert:
// If both old and new values are objectsbinder.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 inimmer-yjs#1.
The library uses transaction origins to prevent circular updates:
constbinder=bind(yMap);binder.subscribe((snapshot)=>{// Safe: won't cause infinite loopif(snapshot.count<10){binder.update((state)=>{state.count++;});}});
The library detects and rejects circular object references:
constcircular:any={a:1};circular.self=circular;binder.update((state)=>{state.data=circular;// ❌ Throws: "Circular reference detected"});
Check out thetest 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 byhttps://github.com/sep2/immer-yjs.
mutative-yjs isMIT licensed.
About
A library for building Yjs collaborative web applications with Mutative
Topics
Resources
License
Uh oh!
There was an error while loading.Please reload this page.