React state management and YJS

Hello,
I am building a small library for using Yjs with React, heavily inspired by the svelte package from Relm.

The basic idea is to create Zustand reactive stores bound to YJS types. Still a work in progress, but I will update it quite a lot this next few months. If you can run the example, test it a bit and submit some issues, will be highly appreciated.

Here the repo: https://github.com/tandem-pt/zustand-yjs

Thanks, and happy coding!
Hadrien

3 Likes

This is great, thank you for sharing!

I was really hoping that somebody would eventually write a React store :slight_smile: I will check out the repository on the weekend.

1 Like

Some updates on my progresses.
I am designing an architectural update of the repository, because it became just impossible to use in practice.

What I have in mind is designing a monolithic zustand store to keep references of all the Y.Doc of the app. This way I can expose some functions to handle array creation and data binding.

Here the API I have in mind:

import {configureConnection, useArray} from 'zustand-yjs';

/**
* Called when a Y.Doc is created (the name of the doc is unknown in the store).
*
**/
configureConnection((yDocName, yDoc, previousYDocs, registerProvider, disconnect) => {
  // Connect yDoc to some provider.
  const provider = new WebrtcProvider(yDocName, ydoc)
  registerProvider(yDocName, () => provider.disconnect());

  // You might want to disconnect others
  previousYDocs.forEach(([name, previousYDoc]) => {
    disconnect(name);
  });
});

const MyComponent =() => {
  /* 
   Data is updated through Y.Array#observe, and is served by the hook 
   as transient data. @see https://github.com/pmndrs/zustand#transient-updates-for-often-occuring-state-changes
  */
  const {data, push} = useArray<{foo: string}>('Root', 'MyArray');
  const newFoo = () => push([{foo: "bar"}]);
  return <div onClick={newFoo}>
    {data.map(({foo}, index) => <span key={index}>{foo}</span>)}
  </div>
}

The internal structures would look like:

/**
 * {
 *   yDocs: {"Root": <a Y.Doc>},
 *   observers: {"Root": {"MyArray": [<Y.Array observer>]}},
 *   data: {"Root": {"MyArray": [{"foo": "bar"}]}},
 * }
 */

I have some concerns regarding this architecture:

  • We observe Y.AbstractType and never unObserve it. Not sure about performance issue, nor a possible resolution.
  • During observe, we actually store Y.Array#toArray or Y.Map#toJSON value in memory. I guess it would be possible to store in Zustand store only a kind of trigger like a refresh: 0|1 and serve data as a proxy to the Y.Array#toArray – this way we don’t store data twice, and rely on y-js performance.

@meatflavourdev I saw on Gitter that you were also interested in a React implementation. Do you have any feedback on this proposal?

This is really great. I wasn’t really familiar with zustand. I’m glad you wrote this because adapting zustand makes a lot of sense.

We observe Y.AbstractType and never unObserve it. Not sure about performance issue, nor a possible resolution.

Couldn’t you just use react effects to trigger an unobserve when the component is unmounted? Similarly to what they propose in https://reactjs.org/docs/hooks-custom.html

During observe, we actually store Y.Array#toArray or Y.Map#toJSON value in memory. I guess it would be possible to store in Zustand store only a kind of trigger like a refresh: 0|1 and serve data as a proxy to the Y.Array#toArray – this way we don’t store data twice, and rely on y-js performance.

I’m not sure if it will make a difference. React probably refers to the state somewhere anyway. Then it makes sense to use the current approach to ensure that toJSON is only called once per iteration.

Just a proposal: configureConnection might be a great way to setup a Yjs document before you use it in a sub-component (e.g. to render the content of a specific document that you first have to download).
The developer could use this hook to setup providers for the document. You could provide an option to cache documents for a time and eventually doc.destroy() them instead of providing options for disconnecting from the network. All associated providers will destroy themselves when the Yjs document is destroyed.

I created a minimal live demo for anyone who is is trying this out: https://stackblitz.com/edit/zustand-yjs?file=index.tsx

How would you recommend to forward Yjs/Zustand state to another component?

Hello, awesome minimal example and thanks for the answers, very inspiring.

I don’t have experience with transient values, will have to write tests. But from what I know, I think that doesn’t matter how to pass values to subcomponents. If you pass state to other components through children, reference will be passed, and as transient updates don’t trigger re-render I think it will work quite efficiently.

Thanks for the proposal, will think about it! Reading this proposal, I am tempted to do a :

const App () => {
   const rootDoc = useYDoc('root', (doc: YDoc) => {
      new WebrtcProvider('root', ydoc)
      // Unmount function, called when no one is listing anymore
      return () => doc.destroy() // or disconnect from provider if you want to keep the YDoc in memory.
  });

  // On Mount: Observe the rootYDoc.getArray('members')
  // On Unmount:
  // 1. unObserve the array on unmount.  
  // 2. Trigger the registered unmount function (see useYDoc) 
  // if no listener exist anymore
  const {data, insert} = useYArray('root', 'members');
}

To be able to do this proposal, I would need to have a Y.Doc.on('destroy') to efficiently remove the Y.Doc reference from the monolithic store. Will have a look at the lib.

Thanks for your contribution! That is precious

I like the implementation of useYDoc :+1:

const {data, insert} = useYArray('root', 'members');

Here I’m concerned that it makes it difficult to get the members data from a nested data type. So it might be better to work with shared data types directly. E.g. useYArray(ydoc.getMap('home').get('members')).

When working with nested shared types you could do something like:


const Member = ({ ymember }) => {
  // The content of Member will automatically update when the name or the description changes
  const { data } = useMap(ymember)
  return <span>My name is { data.name }</span>
}

const MyComponent =() => {
  // Notice that a member is now represented as a Y.Map
  const {data, push} = useArray<{foo: Y.Map<string>}>(ydoc.get('members'));
  const newMember = () => {
    const ymember = new Y.Map()
    ymember.set('name', 'Bond')
    ymember.set('description', 'some content')
    push(ymember)
  };
  return <div onClick={newMember}>
    {data.map((ymemberMap, index) => <Member key={index} ymember={ymemberMap}></Member>)}
  </div>
}

Thanks, I am glad I’ve posted proposal before starting coding :slight_smile:
It is a real food for thoughts:

I haven’t though threw this use-case yet, I definitely need to think more about this. One thing that troubles me, is that once you start to use Y-types, you will have to handle yourself observe/unobserve types. In your example, once you provide an edition capabilities, you need to start observing this ymap (from what I do understand).

Will come back with an updated proposal soon, glad you liked the useYDoc :partying_face:

I thought that observe / unobserve are already handle by useArray / useMap. In any case, this would be easy to implement by unobserving when the component is unmounted.

1 Like

Yes, I’ve been over-complicated the thing in my head. Will go with this!
Looks good enough for now, safe enough to try :wink:

const Member = ({ ymember }) => {
  // The content of Member will automatically update when the name or the description changes
  const { data } = useYMap(ymember)
  return <span>My name is { data.name }</span>
}

const MyComponent =() => {
  const rootDoc = useYDoc('root', (doc: YDoc) => {
      new WebrtcProvider('root', ydoc)
      // Unmount function, called when no one is listing anymore
      return () => doc.destroy() // or disconnect from provider if you want to keep the YDoc in memory.
  });
  const {data, push} = useYArray<{foo: Y.Map<string>}>(rootDoc.getArray('members'));
  const newMember = () => {
    const ymember = new Y.Map()
    ymember.set('name', 'Bond')
    ymember.set('description', 'some content')
    push(ymember)
  };
  return <div onClick={newMember}>
    {data.map((ymemberMap, index) => <Member key={index} ymember={ymemberMap}></Member>)}
  </div>
}

Will try to publish an updated version next week

1 Like

Hi! I’m playing around with your implementation @hfroger and looking at how to use it to add sub-documents to the yjs store. I saw you had a note about this on gitHub, have you thought any more about it? I would be interested in helping / contributing once I understand your code a little better.

1 Like

Happy to hear you are playing with it :slight_smile:!
I am currently implementing a side-project and the sub doc use-case is coming soon to me. I am not sure right now how to handle it, and I sense the API we have now is – almost – sufficient for using sub docs, but will have to test.

Happy to meet if you want to have a short overview of the code and how I am using it.

That’d be super helpful! Thanks for your work on this!