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.
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>
}
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.
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.
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.
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
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
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.
Yes, I’ve been over-complicated the thing in my head. Will go with this!
Looks good enough for now, safe enough to try
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>
}
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.
Happy to hear you are playing with it !
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.
Hey, I recently also struggled with combining React state management and Yjs. This led me to create the useY hook in a new react-yjs package. Feedback would be very welcome.