Vue's reactive objects and Yjs

Vue 3 has great support for reactive objects i.e. changes to the object can be watched and immediately propagated to the UI rendering. Yjs is similarly reactive. I would like to connect the sync power of Yjs with the reactivity of Vue. Ideally just the Vue object should be touched and the rest happens in the background.

I made an example where on each change in Yjs the Vue object gets updated and vice versa. It turned out, this can end up in infinite loops, therefore I just update by property now, if they are not equal.

So my questions are:

  • Is there a simpler or better approach achieving this?
  • Is it ok if Y.Map has deep complexity like nested objects and arrays?
  • And will Yjs be smart enough to tell the partial differences, even if such a large subtree is replaced?

Demo https://viidoo.it/test/test-realtime
Source https://gist.github.com/holtwick/2ddb3a2347945edba204a5230303b1da#file-yjs-vue3-experiment-vue-L51

1 Like

Hi @holtwick,

Perhaps relevalt; I’m currently working on a similar project that binds YJS to MobX and other frameworks, in order to simplify creating yjs-backed applications. My current work is available at http://github.com/yousefED/moby, but I hope to release a larger demo soon. I’ll keep you posted, because when I’m a bit further I think it should be relatively straightforward to build something similar for Vue (and might be able to use your help on this, as I’m more experienced with react myself).

1 Like

Update, I just released a new library that seamlessly integrates Yjs and Vue. See Reactive CRDT: easy to use API to use Yjs and build collaborative apps. Looking forward to hearing your feedback,

1 Like

This is awesome! Thanks so much for your effort. Great work. It is exactly what I was looking for. :tada:

1 Like

What I like about your demo @holtwick is that I can just manipulate JSON and it gets automatically synced.

You run into infinite loops when Yjs updates Vue, Vue updates Yjs, Yjs updates Vue, … You can circumvent this using a locking variable or by using the transaction-origin (which is my preferred solution).

  • Is it ok if Y.Map has deep complexity like nested objects and arrays?

Yes, absolutely. Although, it might be a bad idea to store thousands of entries on a Y.Map. The history can never be collected and we need to keep all created keys (not the values) around forever. But I’m looking to improve the Y.Map implementation to support that.

  • And will Yjs be smart enough to tell the partial differences, even if such a large subtree is replaced?

Yjs doesn’t do that. This must be implemented by yourself. You must specifically tell Yjs which objects changed. Yjs doesn’t compute the minimal differences or something…

Thanks for showing off reactive and hooking it up to Yjs :heart:

There is also GitHub - tandem-pt/zustand-yjs: Zustand stores for Yjs structures. which looks very solid, but is written for React.

1 Like

Thanks @dmonad for the feedback. To summarize: Storing nested data with Y.Map and Y.Array is more efficient (regarding sync changes) than having plain JS objects inside those?

let a = new Y.Map()
a.set('b', new Y.Map())
b.set('c', 1)

Would be more efficient than:

let a = new Y.Map()
a.set('b', {c: 1})

Having nested types allows that two clients can set sub-properties concurrently. E.g.

// user1
b.set('c', 1)
// user2
b.set('d', 2)
// once synced
b.toJSON() // ⇒ { c: 1, d: 2 }

The other approach might be more efficient because you reduce the metadata-overhead produced by Yjs. However, it doesn’t allow to synchronize sub-properties. In some cases, this can even be desirable:

// user 1
a.set('b', { c: 1 })
// user 2
a.set('b', { d: 2 })
// once synced
b.toJSON() // ⇒ either { c: 1 } or { d: 2 }

I suggest deciding whether you need to sync concurrent edits on sub-properties. In many cases, the second approach is more desirable.

In Jupyter Notebooks, I implemented metadata using the second approach to ensure that metadata is always consistent. We could have something like:

metadata = {
  type: 'code',
  execution_count: 0 // execution count is only defined on 'code' cells
}

Assuming we use the first “nesting” approach. When user 1 is changing metadata to metadata.type = 'markdown' and user 2 increments execution count metadata.execution_count = 1, we might end up with…

metadata = {
  type: 'markdown',
  execution_count: 1 // invalid! execution count is only defined on 'code' cells
}

Due to concurrent edits, the integrity of metadata is destroyed. So I suggest using the first approach in this case.

Thanks a lot @dmonad for the clarification. Now I understand the behavior much better. Indeed, for both cases there are valid use cases I didn’t think of before.