Shared Editors

The week of December 5, 2022
author:Trevor Paleytag:weekly-updatetag:rtc





Closed (since last post):

Pull Requests

Opened (since last post):

Merged (since last post):


After a long hiatus of a school break and then writing, development has started back up again!1 It's great to be back to working in the Necode codebase again, and this week I have an exciting new feature to share.

RTC API Changes

But first, declarative RTC has changed once again. Or rather, some new features have been added to it.

The emit function

While I talked about useMediaChannel in the previous post about RTC, there was also a companion hook, useDataChannel for sending plain bytes to peers.2 You would use it like this:

// In practice you'd want to use `useCallback` here, but it's, um, probably fine
const emit = useDataChannel(NetworkId.NET_0, 'my-data-channel', data => {
    // `data` was received from a peer
    console.log('Received data:', data);

// ...

// Broadcast `bytes` to all peers
console.log('Sending data:', bytes);

However, while broadcasting data to all peers might be useful in some cases, it's often just a waste of resources, or may even have unfavorable semantics. The new emit lets you optionally specify which peers to send it to:

const emit = useDataChannel(NetworkId.NET_0, 'my-data-channel', (data, from) => {
    // `data` was received from peer `from`
    console.log('Received data', data, 'from', from);

// ...

// Send `bytes` to just `peer`
console.log('Sending data', bytes, 'to', peer);
emit(bytes, { target: [peer.id] });


In addition to targeting specific peers, it can also be valuable to listen to lifecycle events, which the new useDataChannelLifecycle hook allows you to do.3 A clear example of when this would be useful will be shown in the next section, but it works in a relatively similar manner to useDataChannel:

const emit = useDataChannelLifecycle(
    (event, data, emit) => {
        switch (event) {
            case 'connect':
                // ...
            case 'message':
                // ...
            case 'disconnect':
                // ...

Shared editors

Pardon the awkward delays as I switch between monitors to play both roles in the following video:

This uses Yjs for the shared editor, powered by useDataChannelLifecycle! Rather than sending your full editor state every time there's a change, if you know when someone connects, you can send the full state then, and only incremental changes from then on. Let's see how it's implemented:

export default function useY(network: NetworkId, channel: string) {
    // Create a Yjs document
    const yDoc = useMemo(() => new Y.Doc(), []);

    // Open up a data channel with lifecycle hooks
    const emit = useDataChannelLifecycle(
        // This weird destructuring is just to make TypeScript happy.
        useCallback((...[event, data, emit]) => {
            switch (event) {
                case 'connect':
                    // When we connect, send over our current state
                    console.debug('Sending initial Y state to', data.who.id);
                    emit(Y.encodeStateAsUpdateV2(yDoc), { target: [data.who.id] });
                case 'message':
                    // Whenever we receive an update from a peer, apply it to our own doc
                    Y.applyUpdateV2(yDoc, data.content);
        }, [yDoc]),

    // Effect to establish an event listener on the doc
    useEffect(() => {
        const handler = (update: Uint8Array) => {
            // When there's an update, broadcast it to peers
        // Listen to updates
        yDoc.on('update', handler);
        return () => yDoc.off('update', handler);
    }, [yDoc, emit]);

    // Return the document so that consumers can use it
    return yDoc;

And that's all there is to it! Broadcast changes, apply received updates, and send your full state to newcomers so they can get up to speed.

With the document from useY, it can be hooked into other features like y-monaco for the editor, another hook called useYAwareness for the indicators showing where other users are doing, and so on.

And the good news is, all of this is now in beta, so it should be considered stable-ish for those who want to play around with it.

Cold start

In other news, lambda cold starts are much better now. With the introduction of Next.js 13, a new experimental config feature called outputFileTracingIgnores was added to allow excluding certain modules from the "trace" that's used to package lambdas for AWS. This enabled me to finally get MUI out of the server build, and cold starts are now down to just 2-3 seconds. Much better than the nearly 10 seconds I was getting previously. Hopefully this will make it to production soon as well!


  1. Just in time for winter break too! sigh...

  2. useDataChannel operates on Uint8Arrays (i.e. byte arrays), so there's also yet another hook called useStringDataChannel which does the exact same thing but sends and receives strings. In theory it might be possible to use both of them together to send strings and receive utf-8-encoded byte arrays (and vice versa), but I offer no warranty.

  3. It also allows you to listen for incoming data, though that's not actually a lifecycle event. More of a convenience thing.