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.
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 ofY.XmlElement
s.Y.XmlText
stores text found within aY.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?
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.Map
s:
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.
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.
Exercise
This demo uses two functions that read data from and write data to the Yjs document:
checked(map, key)
takes aY.Map
and a key, and returns a boolean indicating whether that key is set totrue
.toggle(map, key, value)
takes aY.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.
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!
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!