Yjs is a framework that provides algorithms and data structures for real-time collaborative editing. It can offer experiences where multiple people simultaneously update the same content, similar to Notion and Figma.
Yjs provides shared data types such as Y.Map, Y.Array, and Y.Text, which can be used similarly to JavaScript’s Map and Array. Moreover, any changes made to this data are automatically distributed and synchronized with other clients.
Yjs is an implementation of what is called a Conflict-free Replicated Data Type (CRDT) and is designed so that even if multiple people operate on the data at the same time, no conflicts occur and all clients eventually reach the same state.
Quick Start
Let’s look at a code example where Y.Map is automatically synchronized between clients.
import*asYfrom'yjs'// Yjs documents are collections of// shared objects that sync automatically.constydoc=newY.Doc()// Define a shared Y.Map instanceconstymap=ydoc.getMap()ymap.set('keyA','valueA')// Create another Yjs document (simulating a remote user)// and create some conflicting changesconstydocRemote=newY.Doc()constymapRemote=ydocRemote.getMap()ymapRemote.set('keyB','valueB')// Merge changes from remoteconstupdate=Y.encodeStateAsUpdate(ydocRemote)Y.applyUpdate(ydoc,update)// Observe that the changes have mergedconsole.log(ymap.toJSON())// => { keyA: 'valueA', keyB: 'valueB' }
The central component is the Y.Doc (a Yjs document). A Y.Doc contains multiple shared data types and manages synchronization with other clients. You create a Y.Doc for each client session, and each one holds a unique clientID.
Providers
In the example above, multiple users were simulated on the same machine, but if you actually want to synchronize changes over a network, you use providers. Yjs itself is not dependent on any particular network protocol, allowing you to freely switch among various providers or use multiple at the same time. Here are some examples of providers:
y-websocket: Implements a client-server model that sends and receives changes via WebSocket. It’s useful if you want to persist Yjs documents on the server or enable request authentication.
y-webrtc: Synchronizes via peer-to-peer with WebRTC, making fully distributed applications possible.
y-indexeddb: Uses an IndexedDB database to store shared data in the browser, enabling offline editing.
If you’re building an application that involves text editing, you’ll likely use an editor framework like ProseMirror or Quill. Yjs supports many common editor frameworks and can be used as a plugin or extension. In most common use cases, you won’t need to directly manipulate Yjs’s shared data types.
Therefore, if you are using one of these editor frameworks, you’d be better off using a corresponding plugin. However, if you’re building a complex GUI for an application such as a design editor like Figma, there are plenty of cases where you might develop the editor UI from scratch.
In this tutorial, we’ll introduce how to build a collaborative editing application connected to Yjs using React as the UI library, specifically demonstrating an example without using editor bindings.
Demo
In this tutorial, we’ll build a Kanban-style task management application. First, let’s show the final product.
Fork on StackBlitz, open the preview in two tabs, and move your cursor over them!
We’ll implement the following features:
Adding and editing tasks
Managing statuses: To Do / In Progress / Done
Drag-and-drop reordering
Multiple people can operate on it at the same time, and changes are reflected in real time
Displaying other participants’ cursors
You can check the entire app in the following repository:
Using valtio as a Middle Layer Between Yjs and React
Because we’re not using editor bindings this time, we’ll be directly operating on Yjs’s shared data types. However, if React components access these shared data types directly, it will become tightly coupled with Yjs, making testing difficult. Moreover, you might end up mixing React state management and another data flow in ways that can easily introduce bugs.
Hence, we’ll use valtio, a simple proxy-based state management library. By combining valtio with valtio-yjs, you can synchronize valtio’s state with Yjs’s shared data types. This lets React components interact with Yjs data via valtio’s state, making state management much simpler.
Other Libraries Used
In this tutorial, we will also use the following libraries:
TypeScript: A superset of JavaScript that supports static typing. It improves code quality and reduces development-time errors.
Vite: A build tool that provides a fast development environment. Lightweight, easy to use, and supports hot reloading.
CSS Modules: A mechanism for achieving scoped CSS. Helps you manage styles per component and avoid collisions.
nanoid: A fast library for generating unique IDs. In this tutorial, it’s used to generate task IDs.
Project Setup
That was a lot of background. Let’s jump right in! We’ll use Vite’s React and TypeScript template to create a new project called yjs-kanban-tutorial.
// src/main.tsximportReactfrom"react";importreactDomfrom"react-dom/client";importAppfrom"./App.tsx";import"./index.css";constroot=document.getElementById("root");if (!root){thrownewError("Root element not found");}reactDom.createRoot(root).render(<React.StrictMode><App/></React.StrictMode>
);
The type TaskStore defines an object using the taskId as the key and Task as the value. We’ve also provided a filteredTasks() function that returns an array of tasks.
You may wonder, “Why not define TaskStore as an array?” This is to make searching and editing tasks simpler, as we’ll explain later.
proxy() is the foundation of valtio; it creates a proxy object that tracks changes to the object passed to it.
useSnapshot() is a custom hook for using valtio’s proxy object from React components, which returns a read-only snapshot. React components automatically re-render when the object changes.
Let’s see how to use these while we add some functionality. First, let’s make sure we can display tasks using useSnapshot(). We’ll update TaskColumn.tsx so that we can get tasks:
We’ve added an addTask() function. The order value will be computed when we implement drag-and-drop, so for now we’ll use a placeholder.
Next, let’s make TaskAddButton use addTask():
// src/TaskAddButton.tsx
import type { FC } from "react";
import styles from "./TaskAddButton.module.css";
+import type { TaskStatus } from "./types";
+import { addTask } from "./taskStore";
+
+interface Props {
+ status: TaskStatus;
+}
-export const TaskAddButton: FC = () => {
+export const TaskAddButton: FC<Props> = ({ status }) => {
return (
- <button type="button" className={styles.button}>
+ <button type="button" className={styles.button} onClick={() => addTask(status)}>
+ Add
</button>
);
};
// src/TaskColumn.tsx
import type { FC } from "react";
import { TaskAddButton } from "./TaskAddButton";
import styles from "./TaskColumn.module.css";
import { TaskItem } from "./TaskItem";
import type { TaskStatus } from "./types";
import { filteredTasks, useTasks } from "./taskStore";
interface Props {
status: TaskStatus;
}
export const TaskColumn: FC<Props> = ({ status }) => {
const snapshot = useTasks();
const tasks = filteredTasks(status, snapshot);
return (
<div className={styles.wrapper}>
<h2 className={styles.heading}>{status}</h2>
<ul className={styles.list}>
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
- <TaskAddButton />
+ <TaskAddButton status={status} />
</div>
);
};
With this in place, clicking the “+ Add” button on the screen lets us add tasks.
When working with valtio, it’s good to keep in mind:
Displaying data: Use useSnapshot() to retrieve a read-only object.
Changing data: Directly modify the proxy object created by proxy().
You can check the current state of things in the section-2-add-task branch.
Synchronizing Data Across Multiple Clients
We’ve only made it possible to add tasks so far, but even at this stage we can enable collaborative editing. Let’s set up data synchronization across multiple clients. Install the necessary libraries:
npm install yjs valtio-yjs@0.5.1 y-websocket
Warning: The latest version of valtio-yjs, v0.6.0, seems to require valtio at version v2.0.0-rc.0 or later. Since v2 is still an RC release, this tutorial will use v0.5.1.
To synchronize data via a network in Yjs, you need a provider, as mentioned earlier. This time, we’ll be using y-websocket. The client will connect to a single endpoint over WebSocket. The y-websocket package includes a server with an in-memory database, making it easy to persist data as well.
First, let’s enable the WebSocket server. Add an npm script to your package.json:
Let’s take a closer look. A Y.Map named "taskStore.v1" is created inside the Y.Doc instance. You can name it arbitrarily.
WebsocketProvider takes the endpoint, the room name, and the Y.Doc, in that order. The room name can also be any name you like, but in most real-world applications, you’ll likely have multiple rooms and let users pick the room to join, generally by specifying an identifiable value like an ID.
In useSyncToYjsEffect(), bind() from valtio-yjs is called inside a React useEffect hook, passing in the valtio proxy object (taskStore) and the Y.Map (ymap).
This means that any operation on your screen is sent from taskStore → Y.Map → WebSocket to other clients, while changes from other clients arrive via WebSocket → Y.Map → taskStore and update your screen.
Use this useSyncToYjsEffect() in App.tsx:
// src/App.tsx
import type { FC } from "react";
import styles from "./App.module.css";
import { TaskColumn } from "./TaskColumn";
+import { useSyncToYjsEffect } from "./yjs/useSyncToYjsEffect";
const App: FC = () => {
+ useSyncToYjsEffect();
return (
<div className={styles.wrapper}>
<h1 className={styles.heading}>Projects / Board</h1>
<div className={styles.grid}>
<TaskColumn status="To Do" />
<TaskColumn status="In Progress" />
<TaskColumn status="Done" />
</div>
</div>
);
};
export default App;
Now, when you open two browser tabs and add tasks on one tab, they will be instantly reflected in the other tab in real time!
You can now edit the text and have those edits synced via Yjs!
To edit a task, we need to look it up; thanks to the data structure being an object, retrieving it with taskStore[id] is straightforward. If we had used an array, we’d need to do something like taskStore.find(task => task.id === id).
You may feel some friction about directly editing state—task.name = name—as in many React patterns you avoid mutating state directly. However, this is the way valtio works, and there’s a reason for it.
Every change made to a Yjs shared data type is grouped into what’s called a transaction. Whenever you make a change, Yjs sends the update to other clients. Minimizing the scope of these changes helps reduce message size, so it’s important to keep changes as small as possible.
There are several ways to implement list reordering, but we’ll do a simple version of Fractional Indexing here. In short:
All order values are floating numbers such that 0 < index < 1.
We set the position by computing the average of the order values on the elements before and after the new position.
That’s what computeOrder() does.
Fractional indexing allows you to specify a position without touching existing elements’ indices, which is handy. However, doing many reorderings in a row can approach floating-point limits and lead to collisions, in which case you’d have to recalculate all positions. We won’t implement that for simplicity.
We also apply computeOrder() in addTask(). We’ll treat any new tasks as if they’re added at the bottom of the list by taking the average of the last task’s order value and 1.
Next, let’s implement moveTask() to move a task using computeOrder():
We’ll use dnd kit for drag-and-drop. Let’s install it:
npm install @dnd-kit/core
We’ll skip some details of how to use @dnd-kit/core. First, we’ll implement a handler function in the onDragEnd of DndContext. This handler calls moveTask() to drop the task into its new position.
Then, we’ll use useDroppable to define where items can be dropped. Here, we’ll display a marker between tasks to show where the user can drop. Let’s implement DroppableMarker:
So far, we’ve focused on syncing content, but in collaborative editing it’s also important to show “Who’s working where, right now?”—i.e., information about other users, such as cursor positions.
Because this information is only needed while editing and doesn’t require permanent storage, Yjs provides a feature called Awareness CRDT separately from its shared data types. Let’s look at a code example:
// All of our network providers implement the awareness crdtconstawareness=provider.awareness// You can observe when a user updates their awareness informationawareness.on('change',changes=>{// Whenever somebody updates their awareness information,// we log all awareness information from all users.console.log(Array.from(awareness.getStates().values()))})// You can think of your own awareness information as a key-value store.// We update our "user" field to propagate relevant user information.awareness.setLocalStateField('user',{// Define a print name that should be displayedname:'Emmanuelle Charpentier',// Define a color that should be associated to the user:color:'#ffb61e'// should be a hex color})
As you can see with awareness.setLocalStateField(), you can store any data in JSON-encodable format in the Awareness CRDT. Here we store a user’s name and color code, but you could store cursor positions, text selection ranges, icon images, etc.
Let’s implement a feature to synchronize cursor positions using the Awareness CRDT. First, we’ll create a custom hook to handle Awareness CRDT:
We used React’s built-in useSyncExternalStore() to synchronize with an external data store, since valtio-yjs doesn’t support Awareness CRDT.
useSyncExternalStore() is for when you want to reference an external data store and re-render the component whenever that data changes. It checks for changes by comparing snapshots via Object.is(). Because awareness.getStates() returns a Map, we serialize it to a JSON string in getSnapshot() to compare easily.
Next, let’s create a Cursors component that saves and displays cursor data using this custom hook:
// src/App.tsx
import type { FC } from "react";
import styles from "./App.module.css";
import { TaskColumn } from "./TaskColumn";
import { DndProvider } from "./dnd/DndProvider";
import { useSyncToYjsEffect } from "./yjs/useSyncToYjsEffect";
+import { Cursors } from "./yjs/Cursors";
const App: FC = () => {
useSyncToYjsEffect();
return (
<DndProvider>
<div className={styles.wrapper}>
<h1 className={styles.heading}>Projects / Board</h1>
<div className={styles.grid}>
<TaskColumn status="To Do" />
<TaskColumn status="In Progress" />
<TaskColumn status="Done" />
</div>
+ <Cursors />
</div>
</DndProvider>
);
};
export default App;
Here, we capture the mousemove event to update the cursor position and store it in the Awareness CRDT. We also exclude our own cursor data from the array of remote cursors for display.
We can now see the positions of other users’ cursors in real time!
Thank you for this helpful article! Just a quick note:
Warning: The latest version of valtio-yjs, v0.6.0, seems to require valtio at version v2.0.0-rc.0 or later. Since v2 is still an RC release, this tutorial will use v0.5.1.
Thank you for this helpful article! Just a quick note:
Valtio version 2.1.2 is currently available, so it is no longer an RC version.