Y.js Binding
Overview
Since mobx-bonsai v2.0.0, the Y.js binding has been moved to a separate package: mobx-bonsai-yjs.
To use the Y.js binding, install both packages:
npm install mobx-bonsai mobx-bonsai-yjs yjs
# or
yarn add mobx-bonsai mobx-bonsai-yjs yjs
The mobx-bonsai-yjs package provides a binding with Y.js that creates a node that stays in sync with a Y.js state in both directions.
For example, if you already have a Y.js state representing:
interface Todo {
done: boolean
text: string
}
interface TodoAppState {
todoList: Todo[]
}
and that it is already prepopulated with some todos in a Y.js doc map named "todoAppState". All you need to do is:
const {
mobxNode: todoAppState,
dispose
} = bindYjsToNode<TodoAppState>({
yjsDoc,
yjsObject: yjsDoc.getMap("todoAppState"),
})
and from then on you can read/write the state as if it were a MobX observable, this is:
// read
const doneTodos = todoAppState.todoList.filter(todo => todo.done)
// write
const toggleTodoDone = action((todo: Todo) => {
todo.done = !todo.done;
})
toggleTodoDone(todoAppState.todoList[0])
and it will be kept in sync with the Y.js state.
Note that the sync is two-way, so if Y.js state gets updated (manually or via a remote state update), the node will get updates as well. All that means that this:
yjsDoc.transact(() => {
const newTodo = new Y.Map()
newTodo.set("done", false)
newTodo.set("text", "buy milk")
yjsDoc.getMap("todoAppState").getArray("todoList").push([ newTodo ])
})
will also result in a new todo getting added to todoAppState.todoList after the transaction is finished.
And of course, since this is a mobx-bonsai enhanced MobX observable in the end, you can use reaction, autorun, when, computed, getParent, getSnapshot...
Working with Initial State
What if I don't have an intial Y.js state yet?
You can create one like this:
applyPlainObjectToYMap(
yjsDoc.getMap("todoAppState"),
{
todoList: []
}
)
Frozen Nodes in Y.js Binding
When using frozen nodes with the Y.js binding, the frozen node's value is stored as a plain JavaScript value rather than being converted to a Y.Map. In other words, since frozen nodes are immutable and non-observable, they remain as plain objects in the Y.js data store. This ensures that:
- The underlying Y.js structure reflects the immutability of the node.
- There is no extra mapping overhead, which can improve performance.
- The snapshot of a frozen node is preserved as defined.
Transaction Origin Symbols
The bindYjsToNode function accepts an optional yjsOrigin parameter that can be used to control Y.js transaction origins. This is particularly useful when you want to distinguish between local changes and remote changes, or when managing multiple bindings.
The yjsOrigin parameter accepts either:
- A
symbol- A static origin symbol used for all transactions - A
() => symbolfunction - A getter function that dynamically returns the current origin symbol
// Static symbol
const myOrigin = Symbol("my-binding")
const { node } = bindYjsToNode<TodoAppState>({
yjsDoc,
yjsObject: yjsDoc.getMap("todoAppState"),
yjsOrigin: myOrigin,
})
// Dynamic symbol getter
let currentOrigin = Symbol("origin1")
const { node } = bindYjsToNode<TodoAppState>({
yjsDoc,
yjsObject: yjsDoc.getMap("todoAppState"),
yjsOrigin: () => currentOrigin, // Function is called for each transaction
})
When Y.js changes occur with the same origin symbol, they won't trigger updates to the bound MobX node (preventing infinite loops). This is useful for filtering out self-originated changes in collaborative scenarios.
If no yjsOrigin is provided, one will be automatically generated.
Binding Limitations
Y.js binding limits:
- Changes in the MobX observable are replicated to
Y.jsonly after the outermost MobX action has completed. Therefore, avoid executing anyY.jstransactions until MobX actions finish. Y.jschanges are merged into the MobX observable only after allY.jstransactions have concluded. Consequently, do not initiate MobX actions that modify the bound object during an ongoingY.jstransaction.
Utility Functions
getYjsValueForNode
The getYjsValueForNode function returned by the bind function resolves a Y.js value corresponding to a given node within a bound tree. This is useful when you need to map a node back to its Y.js counterpart.
If the target node is not part of the bound tree it throws an error.
import * as Y from "yjs"
import { bindYjsToNode } from "mobx-bonsai-yjs"
const { node, getYjsValueForNode } = bindYjsToNode<TodoAppState>({
...
});
const firstTodoYjs = getYjsValueForNode(todoAppState.todoList[0]);