Skip to main content

Y.js Binding (mobx-keystone-yjs)

The mobx-keystone-yjs package keeps a Yjs document and a mobx-keystone store synchronized in both directions. Yjs is a CRDT, so replicas can accept local changes while offline and merge concurrent edits when they reconnect. You supply the networking and persistence layer appropriate for your application.

Installation

Install the binding and its peer dependencies:

npm install mobx mobx-keystone yjs mobx-keystone-yjs

Binding Y.js data to a model instance

const {
// The bound mobx-keystone instance.
boundObject,
// Disposes the binding.
dispose,
// The Y.js origin symbol used for binding transactions.
yjsOrigin,
} = bindYjsToMobxKeystone({
// The mobx-keystone model type.
mobxKeystoneType,
// The Y.js document.
yjsDoc,
// The bound Y.js data structure.
yjsObject,
})

The yjsObject must be a Y.Map, Y.Array, or Y.Text attached to yjsDoc. Its current JSON representation must be a valid input snapshot for mobxKeystoneType. The returned boundObject is created from that CRDT data, and later changes flow in both directions until dispose() is called.

First migration - converting JSON to Y.js data

If you already have a stored model snapshot, use convertJsonToYjsData to create the initial Yjs structure before binding it.

convertJsonToYjsData accepts a JSON value (usually the model snapshot) and returns the corresponding Yjs structure (Y.Map, Y.Array, and so on). Frozen values remain immutable plain values.

If you already have an existing Y.Map or Y.Array and want to copy JSON data into it, use the helper functions:

  • applyJsonObjectToYMap(dest, source, options?)
  • applyJsonArrayToYArray(dest, source, options?)

These are useful when seeding or refreshing part of a document without manually building nested Y.js containers yourself.

The optional options.mode controls how data is applied:

  • "add" - Create and insert new Y.js containers from the JSON data. This is the default.
  • "merge" - Recursively merge into existing Y.Map / Y.Array containers when possible, preserving existing container references.

For example:

const ymap = ydoc.getMap("rootStore")

// First seed
applyJsonObjectToYMap(ymap, snapshot)

// Later, merge an updated snapshot while preserving existing nested containers
applyJsonObjectToYMap(ymap, nextSnapshot, { mode: "merge" })

Using Y.Text as a model node

The special model YjsTextModel can be used to bind a Y.Text to a mobx-keystone model.

const text = YjsTextModel.withText("Hello world!")

// once `text` is part of a bound tree:
text.yjsText.insert(0, "Say: ")
text.text // "Say: Hello world!"

Note that yjsText throws if you access it while the model is not part of a bound tree. This is due to a limitation of Y.js, since it only allows limited manipulation of types while they are outside a Y.Doc tree.

The YjsBindingContext

All nodes inside a bound tree have access to a YjsBindingContext instance.

The instance can be accessed using:

yjsBindingContext.get(nodePartOfTheBoundTree)

And this instance provides access to the following data:

  • yjsDoc: The Y.js document.
  • yjsObject: The bound Y.js data structure.
  • mobxKeystoneType: The mobx-keystone model type.
  • yjsOrigin: The origin symbol used for transactions.
  • boundObject: The bound mobx-keystone instance.
  • isApplyingYjsChangesToMobxKeystone: Whether we are currently applying Y.js changes to the mobx-keystone model.

Example

A full example is available here.