Skip to content
Lesson 01

Introduction

Getting acquainted with Yjs.

Yjs is a CRDT for building realtime collaborative applications. You’d use it to create multiplayer experiences like Figma or Google Docs.

CRDT stands for Conflict-free Replicated Data Type. They’re a special kind of data structure that can take state stored on multiple different computers, and merge it all together without conflicts. What makes CRDTs special is that they don’t depend on centralized servers to merge the state — any client can do it!

At a high level, there are two parts to Yjs: documents and providers.

A Yjs document is the part that holds all the state. Anything that can be serialized into JSON can also go into a document. Whenever a user moves their cursor, edits text or toggles a checkbox, they’re interacting with the document. And when a document receives updates from another client, it seamlessly merges them together.

How does a document receive updates? That’s where providers come in: they take a Yjs document and sync the changes elsewhere. Usually, that involves wrapping a network technology like WebSockets to send documents between clients. But really, they can send documents anywhere; for example, a provider could sync documents with your browser’s local storage for offline access.

That gives app developers a lot of flexibility! Because all the parts are modular and open source, Yjs is easy to fit into any application — new or old. Rather than locking themselves into a proprietary service, developers can build using an open format with infrastructure they control.

We’ll talk more about providers later. For now, let’s start with…

Documents

Creating a Yjs document is pretty simple:

const doc = new Y.Doc();

Since Yjs is a CRDT, it can merge any two documents together. If you and a friend both have copies of the same document, Yjs will always be able to show you your friend’s changes, and vice versa.

Below, you can see how Yjs syncs documents. Remember, each box below represents a client. They’re real apps running Yjs! When you move a slider on one client, it’ll automatically be updated on the other. Underneath each client is a JSON representation of the Yjs document state.

Client 1
Client 2

This isn’t the whole story, of course — under the hood, Yjs uses a binary format with some extra metadata so that it can reliably sync documents.

One important piece of metadata is the client ID. It’s a random number that uniquely identifies a client for a session. Every time you create a Yjs document, you get a new client ID.

You can see the local client’s ID by checking the clientID property on the document.

const doc = new Y.Doc();
console.log(doc.clientID); // 1090160253, or some other similiarly-long random number

Remember this — it’ll come in handy later! But for now, let’s move onto storing and syncing data.

Shared Types

We said before that a Yjs document is like a container for your state. To read and update that state, we use a feature called shared types. They’re similar to JavaScript data structures such as Map — except shared types automatically sync between clients.

All in all, there are six shared types:

  • Y.Array stores an ordered list of items.
  • Y.Map stores key/value pairs.
  • Y.Text stores a string of plain or formatted text.
  • Y.XmlElement stores a node found in an HTML-like tree structure.
  • Y.XmlFragment stores a collection of Y.XmlElements.
  • Y.XmlText stores text found within a Y.XmlElement.

Usually, you’ll be working with Y.Array, Y.Map and Y.Text. The other three are generally used for complex rich text editors such as ProseMirror or Quill, and Yjs’ editor integrations mean that you probably won’t need to worry about them yourself.

Why Y.Text instead of a normal string?

Yjs can store normal JavaScript strings, but they behave a little differently than you might expect.

Let’s say you and your friend were both editing a document containing the sentence “The red dog.” You add the word “bright” to change the sentence to “The bright red dog.” Meanwhile, your friend adds an “s” to the end, so it becomes “The red dogs.”

If the sentence were stored as Y.Text, the final result would be “The bright red dogs.”

However, if the sentence were stored as a normal string, Yjs wouldn’t be able to merge them. The final result would be either “The bright red dog” or “The red dogs.” One of your changes would be overwritten!

There are two ways to instantiate a shared type. The first is to attach it to the document itself. These types are called top-level types, because they’re direct children of the document.

const doc = new Y.Doc();
const example = doc.getMap("example");

Even though it looks like this is “getting” something, it actually creates a map inside the document. If you represented the document data as JSON, it would look like this:

{
  "example": {}
}

Some shared types can also contain other shared types! When you’re working with types that are not directly attached to the document, you can instantiate them the way you would any other JavaScript class.

Here’s how you might model a todo list using a Y.Array that contains Y.Maps:

const doc = new Y.Doc();
const todos = doc.getArray("todos");

const item = new Y.Map([["text", "Buy milk"], ["done", true]]);
todos.push(item);

One difference between shared types and their JavaScript counterparts is that each shared type holds a reference to its parent:

const doc = new Y.Doc();
const top = doc.getMap('parent');

const child = new Y.Map();
top.set('child', child);

console.assert(child.parent === top);

Also, all shared types hold a reference to their document:

const doc = new Y.Doc();
const top = doc.getMap('parent');

const child = new Y.Map();
top.set('child', child);

console.assert(top.doc === doc);
console.assert(child.doc === doc);

Okay, that’s enough background. Let’s dig into our first shared type: Y.Map!

Maps

Like JavaScript’s Map object, Y.Map holds key/value pairs and remembers the order in which they were inserted. The two APIs are very similar: you create them in the same way, and they have many of the same methods.

const map = new Map();
const ymap = new Y.Map();

map.set("example", "value");
ymap.set("example", "value");

console.assert(map.get("example") === "value");
console.assert(ymap.get("example") === "value");

Here’s an example app that uses Y.Map: a short checklist, kept in sync between two clients.

Client 1
Client 2

Here’s how the example behaves with latency added. You can move the latency slider on top to control how long it will take for any changes to be synced.

Client 1
Client 2

Exercise

This demo uses two functions that read data from and write data to the Yjs document:

  • checked(map, key) takes a Y.Map and a key, and returns a boolean indicating whether that key is set to true.
  • toggle(map, key, value) takes a Y.Map, a string key and a value, and sets the map’s key to the value.

In the playground below, both functions are implemented for you.

Try changing map.get(key) === true to map.get(key) === false and see how the interface changes. You can press ctrl/cmd+s to save your changes, or just wait a few seconds after typing.

Client 1
Client 2
Console

    Here’s a small exercise to get you acquainted. Implement a function done that takes a Y.Map and returns true only if all keys are true. You can look at the Y.Map documentation for a full list of properties.

    When you’ve written it correctly, the unit tests below should all pass. If you’re having trouble, don’t feel bad about looking at the hints!

    Client 1
    Not finished
    Client 2
    Not finished
    Console
      Unit tests1/2
      Returns false when any key is false
      Returns true when all keys are true
      All keys were true, but done() returned false
      Hint

      Start by taking it easy! We know all the keys in this map, so we can just hardcode everything. Check each key one by one, and return false if any of them are false. If none of them are false, return true.

      Don’t worry — there will be exercises where things are a bit more dynamic!

      Click to reveal solution

      Solution

      Since we know all the keys in this map, we can just check them one by one and return false if we find a key that isn’t true. At the end of the function, if we haven’t already returned false, we know all the keys are true:

      export function done(map) {
        if (!map.get("one")) return false;
        if (!map.get("two")) return false;
        if (!map.get("three")) return false;
      
        return true;
      }

      Hardcoding can feel kind of icky, so here’s what the solution would look like if we wanted to do it iteratively:

      export function done(map) {
        for (const value of map.values()) {
          if (!value) return false;
        }
      
        return true;
      }

      Now that we have a handle on how Yjs works, let’s start looking at some trickier problems!