Reactive CRDT: easy to use API to use Yjs and build collaborative apps

Hi all! A couple of weeks ago I’m got started with Yjs. I loved the project and its potential to develop local-first, distributed applications.

However, I noticed a bit of a steep learning curve at the beginning (“How do all these shared types work? How do I compose them?”). After getting the gist of it, wanted to see if I can build a really easy library to use Yjs.

The first version of Reactive CRDT is now live at https://github.com/yousefED/reactive-crdt. I really appreciate any feedback!

(Have a look at the TODO MVC example and the examples in the readme)

It works particularly easy with React, but it works great standalone as well.

TLDR;

  • store.property.value = 5 instead of doc.getMap("store").getMap("property").set("value", 5)
  • Reactive model that automatically observes changes: autorun(() => { console.log(store.property.value)}) instead of something like doc.getMap("store").observeDeep((event) => console.log(doc.getMap("store").getMap("property").get("value"));

Hope you’ll try it out!

5 Likes

A couple of notes, or things to think about:

  • Some of the imports say import "@reactivedata/reactive"; and others import "@reactivedata/reactive-crdt";
  • Your API makes it extremely simple to map JSON to Yjs types (Y.Map & Y.Array). On the other hand, Y.Xml & Y.Text can’t be mapped to JSON, so maybe it would make sense to export them directly instead? Then it would be easier to create editor bindings within react / vue applications.

I love the whole concept of reactive. I didn’t know that reactive allows to manipulate JSON directly. This is seriously awesome. Did you already publish a package? I want to create a new section in docs.yjs.dev for these kinds of projects. Maybe it would even make sense to link to this in the tutorial section.

Thanks for creating it! :heart:

1 Like

You’re welcome! Would be great to be featured!

  • I’ve just added support for Y xml and text, just use object.property = new Y.Text(). Package has been published! (unfortunately it still uses a fork of Yjs untill we decide about https://github.com/yjs/yjs/pull/298)
  • it’s also possible to pass boxed values with store.property = boxed({prop: val}). Boxed values will not be converted to a Y.Map / Y.Array, but stored as plain JSON (similar to ymap.set("property", {prop: val}) in Yjs

Regarding “reactive” vs. “reactive-crdt”. “reactive” is a separate library I wrote (mostly as a basis for “reactive-crdt”). It’s similar to MobX, NX Observe, or Vue3’s “reactive” function. It brings Functional Reactive Programming (FRP) to Javascript: simply put; the concept of automatic observable values and observers / reactions, instead of having to deal with events all over the place.

reactive-crdt” is the easy-to-use API for Yjs. It can be used with either “reactive”, MobX, or Vue3 reactive (technically, it could also be used without any FRP library, but it would make it difficult to observe changes). The demo in the readme uses the “reactive” package as it has the simplest API. Pretty much all “reactive-crdt” is exposed in just the crdt function!

Coincidentally I’ve been working on a similar thing as a wrapper for the @vue/reactivity package Basic Reactivity APIs | Vue.js (vuejs.org). (basically it proxies a vue reactive object, and sets up data syncing between a vue reactive object and corresponding nested shared types)

The issue I’ve recently run into though is around initialising state offline. It works fine at the doc level, if the associated shared type is at the doc level, i.e. doc.getArray().

However an array that is nested in a map, each client will initially create its own new Y.Array() and insert it into the map, and essentially one of these arrays will be discarded on sync.

Or another example I’ve found is having a primitive value in the json, e.g. a number 0, if the document gets updated to have that field as a 1, when a new client connects it can reset that value to the initial value of 0.

Did you figure out a way around this? or am I overthinking a non-problem (e.g. is it really that hard to ensure some sort of ‘sync’ event before initialising)?

I’ve actually added Vue support to https://github.com/yousefED/reactive-crdt today. Would love your feedback!

Regarding the initial values: that’s not solved in this library. I think it’s better to discuss that in this thread. The topic has come up quite a bit lately, so it would be good to collect feedback and thoughts there.

Looking at the api around the vue support I do have a few preferences/suggestions, which are coincidentally how our apis differ…

That example of using the data field in define component does a great job at highlighting how simple it can be to use for people familiar with vue so A+ on that.

I went a quite different route with my api since in vue3 the reactivity logic is actually separated out from the component framework completely, and I want to use this in environments not supported by the vue framework such as worker threads.

its along the lines of

const fruits = reactive([{ name: 'Apple', weight: 2 }]) // use the vue 'reactive' api
const syncedFruits = vuey(fruits, () => {
  const lists = doc.getMap('lists')
  if (!lists.has('fruits')) lists.set('fruits', new Y.Array())
  return lists.get('fruits')
})

Then you can use syncedFruits as a regular vue reactive object where needed.

The parameters are:

  1. The initial data in a vue reactive object - it doesn’t work well with an offline first strategy, but I have a somewhat successful, if hacky, method of ensuring initialising once (provided every client is synced first).
  2. A sort of Y.Doc mount point getter. This allows the client to check if a nested map or array already exists before creating a new one.

I don’t really think this is the neatest api, but it provides me with a lot of flexibility. As I said I can use this in worker threads, and as
dmonad asked earlier, for instance, if I want to have an xmlFragment in the object it can be this:

vuey(reactive({ counter: 0, text: markRaw(new Y.XmlFragment()) }, () => doc.getMap())

here markRaw and reactive are both from vue’s own reactivity library. markRaw basically tells vue reactive to not react to internal changes of the xmlFragment.

Thanks for the feedback @alexbakerdev!

reactive-crdt should integrate nicely with vue’s “reactive” (it’s also what the data-example uses under the hood) and accommodate your use-case. Let me know (maybe on github) if I can make this clearer in the docs.

The reactive-crdt version of your first example would be:

// setup
import * as Vue from "vue";
import { crdt, useVueBindings } from "@reactivedata/reactive-crdt";
useVueBindings(Vue);

const lists = Vue.reactive(crdt({ fruits: [{ name: 'Apple', weight: 2 }]}));
if (!lists.fruits) lists.fruits = [];

Now you can do with lists.fruits whatever you want. Nested values are also automatically transformed, so { name: 'Apple', weight: 2 } is a YMap internally.

Your second example would be:

Vue.reactive(crdt({ counter: 0, text: new Y.XmlFragment() }));

With reactive-crdt, your application should actually respond to text / xml changes, so you don’t have to call .observe manually.

Hey @YousefED thanks a lot for creating this library, i was wondering what is the solution for Vue2, for now I’m using the without any framework option.

I would greatly appreciate any guidance you could provide.

Hi and welcome to the forums!

Unfortunately, there are no plans to support Vue 2. In Vue 3, the “reactivity” system of Vue has been rewritten, and this is what reactive-crdt relies on. I have not explored Vue 2 enough in-depth to estimate the work required for an integration.

Of course, you can use the “plain” reactive-CRDT library as is :slight_smile: How has your experience been so far?

Can you try the combination of rxjs and YJS, I think

@YousefED Very cool! Thank you for creating and releasing this library.

I’m working on an application for which I think I would like to use Yjs subdocuments, and I’m curious if you have ideas about subdoc support in this reactive-crdt library?

I’m interested in subdocs because I want to have multiple awarenesses throughout the doc; mostly for having many text types mounted across the doc, but for a few other awarenesses, too, and my understanding is that there’s a “1 awareness per doc” model.

@YousefED Was kind enough to demo Reactive CRDT (now named SyncedStore) in the last Y Community meeting.

my understanding is that there’s a “1 awareness per doc” model.

@jasonm You are free to use the same awareness instance in different docs and in different providers. Actually, the doc itself doesn’t even know about the awareness instance. We only instantiate the Awareness instance with a Y.Doc so we can reuse the unique ID.

Actually, Yjs and Awareness are simply two independent CRDTs that you can use alongside each other.

3 Likes

I saw a ref to this video on Twitter and am very excited to watch :grinning_face_with_smiling_eyes:

@dmonad Thanks! I’ve spent a lot more time reading and experimenting with Yjs this week and now understand my original question was a bit misguided. I’m now thinking about a single-doc approach, and thinking more about how multiple awareness consumers (mouse X/Y, tree/block presence like Notion, text edit cursors, etc.) can share the awareness keyspace. I’m thinking about multiple instances of the same editor within a doc here Multiple text types per doc + multiple editors in the UI · Discussion #310 · BitPhinix/slate-yjs · GitHub but really this could generalize to some lightweight standard where various libraries who participate in the awareness CRDT agree on how to share the keyspace safely. Sorry, I’m digressing a bit from this SyncedStore thread. Also, perhaps this is very much an application-level concern rather than a library concern… I think if libraries expose a way for the application to provide some sort of awarenessRootKey then the responsibility of structuring the awareness crdt can rest wholly with the application.

This is indeed very exciting. When I was first evaluating Yjs for the shared data model for a React application, I wondered if there was some kind of proxy library which wrapped the Yjs API in a mutable API, like Immer.js – SyncedStore looks wonderful!

I had recently been working on a library for a large React/Redux application which syncs a stream of Redux actions over client/server websockets and solved some interesting problems around optimistic updates, resolving conflicts, etc. But I am quite tempted to move to a Yjs/SyncedStore approach instead.

Thanks for sharing the Y Community video about SyncedStore. I noted a few questions or comments for @YousefED while watching and thought I would share:

  1. There are some parallels here with redux and memoized selectors (reselect etc) - I had a question about how this works, but you answered it. :grin:
  2. This is reminiscent of Immer.js in its usage of proxies to provide a more usable API. You mentioned that the SyncedStore proxy code is quite complete (support for e.g. splice that Kevin asked about) – are there any of Immer’s test suites that could be ported to SyncedStore? I have not looked under the hood of SyncedStore at all, just a thought that occurred to me.

Thanks again for sharing!

Hi @jasonm .

Happy to hear you like SyncedStore - definitely keep me posted of your findings as you start using it :slight_smile:

  • The reactive system is in some ways similar to Immer, but is really more close to MobX (Immer focuses on Immutable data, where Mobx works on mutable data and adds a reactivity layer).
  • Creating and loading subdocs is currently not (easily) supported in SyncedStore. It would definitely be possible to add support for it later. For now, what is possible is to create a syncedStore based on a subdocument as soon as you have access to it, using syncedStore({ ...shape...}, subdoc) (i.e.: pass the subdoc as second paramter). This will allow you to use the SyncedStore API + reactive system against your subdocument.

Hope this helps! If you have more questions it might be easier to create separate topics to keep this thread organized btw

1 Like

I was trying to figure out how the “undo” and “redo” will work using the Synced Store.

  const undo = () => {
    const doc = getYjsValue(state) as Y.Doc;
    const undoManager = new Y.UndoManager(doc.getText("content"));
    undoManager.undo();
  };

I was trying to do this, but it didn’t work. Please suggest.

Y.UndoManager works by capturing update events and reverting them, so it needs to be created before executing updates:

const text = doc,getText('content')

const undoManager = new Y.UndoManager(text)

// Do stuff to text (Let undoManager capture the updates)

undoManager.undo() // Revert the captured updates
2 Likes

Thanks, I was able to do this.

How do I use, useReducer in conjunction with the synced store, or it’s just either, currently I have a bunch of warnings when trying to use useSyncedStore inside a Provider.