This article is an English translation of the following article:
チュートリアル: Yjs, valtio, React で実現する共同編集アプリケーション - ROUTE06 Tech Blog
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 * as Y from 'yjs'
// Yjs documents are collections of
// shared objects that sync automatically.
const ydoc = new Y.Doc()
// Define a shared Y.Map instance
const ymap = ydoc.getMap()
ymap.set('keyA', 'valueA')
// Create another Yjs document (simulating a remote user)
// and create some conflicting changes
const ydocRemote = new Y.Doc()
const ymapRemote = ydocRemote.getMap()
ymapRemote.set('keyB', 'valueB')
// Merge changes from remote
const update = Y.encodeStateAsUpdate(ydocRemote)
Y.applyUpdate(ydoc, update)
// Observe that the changes have merged
console.log(ymap.toJSON()) // => { keyA: 'valueA', keyB: 'valueB' }
(Quoted from https://docs.yjs.dev/#quick-start)
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.
For other providers, please see the official documentation: https://docs.yjs.dev/ecosystem/connection-provider
Editor Bindings
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.
Check out the following list of editor frameworks supported by Yjs: https://docs.yjs.dev/ecosystem/editor-bindings
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:
yjs-kanban-tutorial
- EN: Tutorial: Building a Collaborative Editing App with Yjs, valtio, and React - DEV Community
- JA: チュートリアル: Yjs, valtio, React で実現する共同編集アプリケーション - ROUTE06 Tech Blog
This repository is sample code for the above.
If you find any inadequacies in the tutorial, we would appreciate it if you could let us know at https://github.com/route06/yjs-kanban-tutorial/issues/new .
このリポジトリは上記のサンプルコードです。
チュートリアルの内容に不備があった場合、 https://github.com/route06/yjs-kanban-tutorial/issues/new でお知らせ下さると幸いです。
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
.
npm create vite@latest yjs-kanban-tutorial -- --template react-ts
First, set up the type definitions for tasks. In this tutorial, tasks have fields for status, value, and order.
// src/types.ts
export type TaskStatus = "To Do" | "In Progress" | "Done";
export interface Task {
id: string;
status: TaskStatus;
value: string;
order: number;
}
Next, let’s build out the UI at once. We’ll add these three components:
- TaskAddButton
- TaskItem
- TaskColumn
Since they’re just for styling at this point, we won’t go into detail. Please copy and paste the code as is.
TaskAddButton
// src/TaskAddButton.tsx
import type { FC } from "react";
import styles from "./TaskAddButton.module.css";
export const TaskAddButton: FC = () => {
return (
<button type="button" className={styles.button}>
+ Add
</button>
);
};
/* src/TaskAddButton.module.css */
.button {
width: 100%;
text-align: left;
font: inherit;
cursor: pointer;
border: none;
border-radius: var(--rounded-sm);
padding: 0.375rem;
background-color: var(--zinc-950);
color: var(--zinc-400);
font-size: 0.75rem;
transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.button:hover {
background-color: var(--zinc-900);
}
.button:focus-visible {
outline: 1px solid var(--zinc-400);
border-radius: var(--rounded-sm);
}
TaskItem
// src/TaskItem.tsx
import type { FC } from "react";
import styles from "./TaskItem.module.css";
import type { Task } from "./types";
interface Props {
task: Task;
}
export const TaskItem: FC<Props> = ({ task }) => {
return (
<li className={styles.listitem}>
<button type="button" className={styles.button}>
<svg
width="24"
height="24"
viewBox="6 0 12 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>Drag</title>
<circle cx="9" cy="12" r="1" />
<circle cx="9" cy="5" r="1" />
<circle cx="9" cy="19" r="1" />
<circle cx="15" cy="12" r="1" />
<circle cx="15" cy="5" r="1" />
<circle cx="15" cy="19" r="1" />
</svg>
</button>
<input className={styles.input} value={task.value} />
</li>
);
};
/* src/TaskItem.module.css */
.listitem {
background-color: var(--zinc-900);
border-radius: 0.25rem;
padding: 0.5rem 0.25rem;
list-style: none;
display: flex;
gap: 0.2rem;
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.input {
width: 100%;
background-color: var(--zinc-900);
border: 0;
color: var(--zinc-100);
padding: 0.25rem;
}
.input:focus-visible {
outline: 1px solid var(--zinc-400);
border-radius: var(--rounded-xs);
}
.button {
cursor: move;
border: none;
background-color: transparent;
padding: 0;
color: var(--zinc-400);
}
.button:focus-visible {
outline: 1px solid var(--zinc-400);
border-radius: var(--rounded-xs);
}
TaskColumn
// src/TaskColumn.tsx
import type { FC } from "react";
import { TaskAddButton } from "./TaskAddButton";
import styles from "./TaskColumn.module.css";
import { TaskItem } from "./TaskItem";
import type { Task, TaskStatus } from "./types";
interface Props {
status: TaskStatus;
}
export const TaskColumn: FC<Props> = ({ status }) => {
// TODO
const tasks: Task[] = [
{ id: "1", status, value: "Task 1", order: 1 },
{ id: "2", status, value: "Task 2", order: 2 },
{ id: "3", status, value: "Task 3", order: 3 },
];
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 />
</div>
);
};
/* src/TaskColumn.module.css */
.wrapper {
background-color: var(--zinc-950);
border-radius: var(--rounded-md);
border: 1px solid var(--zinc-800);
padding: 0.5rem;
}
.heading {
font-size: 1rem;
font-weight: 400;
margin-bottom: 1rem;
}
.list {
list-style-type: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
Next, replace the contents of App.tsx, main.tsx, App.css, and index.css with the following. Please delete the existing code.
// App.tsx
import type { FC } from "react";
import styles from "./App.module.css";
import { TaskColumn } from "./TaskColumn";
const App: FC = () => {
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;
// src/main.tsx
import React from "react";
import reactDom from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element not found");
}
reactDom.createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
/* src/App.module.css */
.wrapper {
margin: auto;
padding: 0.5rem;
}
.heading {
font-size: 1rem;
font-weight: 400;
padding-bottom: 0.5rem;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
/* src/index.css */
:root {
font-family: "Inter", sans-serif;
--zinc-950: #09090b;
--zinc-900: #18181b;
--zinc-800: #27272a;
--zinc-700: #3f3f46;
--zinc-400: #a1a1aa;
--zinc-100: #f4f4f5;
--rounded-md: 0.375rem;
--rounded-sm: 0.25rem;
--rounded-xs: 0.125rem;
}
html {
background-color: var(--zinc-900);
}
body {
color: var(--zinc-100);
}
* {
margin: 0;
box-sizing: border-box;
scrollbar-width: thin;
}
Notice that we changed App.css
to App.module.css
.
When you run npm run dev
at this point, the screen should look like this:
You can check out the current state of this setup in the section-1-setup-project branch.
Implementing Task Addition
Let’s enable the addition of tasks. We’ll install valtio for state management and nanoid for generating IDs.
npm install valtio nanoid
We’ll use valtio to manage the state of the tasks. Add a file called taskStore.ts
as follows:
// src/taskStore.ts
import { proxy, useSnapshot } from "valtio";
import type { Task, TaskStatus } from "./types";
interface TaskStore {
[taskId: string]: Task;
}
export const filteredTasks = (
status: TaskStatus,
taskStore: TaskStore
): Task[] => Object.values(taskStore).filter((task) => task.status === status);
export const taskStore = proxy<TaskStore>({});
export const useTasks = () => useSnapshot(taskStore);
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:
// src/TaskColumn.tsx
import type { FC } from "react";
import { TaskAddButton } from "./TaskAddButton";
import styles from "./TaskColumn.module.css";
import { TaskItem } from "./TaskItem";
-import type { Task, TaskStatus } from "./types";
+import type { TaskStatus } from "./types";
+import { filteredTasks, useTasks } from "./taskStore";
interface Props {
status: TaskStatus;
}
export const TaskColumn: FC<Props> = ({ status }) => {
- // TODO
- const tasks: Task[] = [
- { id: "1", status, value: "Task 1", order: 1 },
- { id: "2", status, value: "Task 2", order: 2 },
- { id: "3", status, value: "Task 3", order: 3 },
- ];
+ 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 />
</div>
);
};
We’ve replaced the dummy data so that we now get data from the read-only snapshot returned by useSnapshot
.
However, because taskStore
has no data right now, nothing will display. Let’s make it possible to add new tasks:
// src/taskStore.ts
import { proxy, useSnapshot } from "valtio";
import type { Task, TaskStatus } from "./types";
+import { nanoid } from "nanoid";
interface TaskStore {
[taskId: string]: Task;
}
export const filteredTasks = (status: TaskStatus, taskStore: TaskStore): Task[] =>
Object.values(taskStore).filter((task) => task.status === status)
export const taskStore = proxy<TaskStore>({});
export const useTasks = () => useSnapshot(taskStore);
+export const addTask = (status: TaskStatus) => {
+ const order = 0; // dummy
+ const id = nanoid();
+ taskStore[id] = {
+ id,
+ status,
+ value: "",
+ order,
+ };
+};
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:
// package.json
{
"scripts": {
"dev-websocket": "y-websocket --port 1234"
}
}
Then run npm run dev-websocket
:
$ npm run dev-websocket
> yjs-kanban-tutorial@0.0.0 dev-websocket
> y-websocket --port 1234
running at 'localhost' on port 1234
Now add yjs/yjs.ts
to manage the Yjs document, and yjs/useSyncToYjsEffect()
to synchronize taskStore via Yjs:
// src/yjs/yjs.ts
import { WebsocketProvider } from "y-websocket";
import { Doc } from "yjs";
const ydoc = new Doc();
export const ymap = ydoc.getMap("taskStore.v1");
new WebsocketProvider("ws://localhost:1234", "yjs-kanban-tutorial", ydoc);
// src/yjs/useSyncToYjsEffect.ts
import { useEffect } from "react";
import { bind } from "valtio-yjs";
import { taskStore } from "../taskStore";
import { ymap } from "./yjs";
export const useSyncToYjsEffect = () => {
useEffect(() => {
const unbind = bind(taskStore, ymap);
return () => {
unbind();
};
}, []);
};
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!
Check section-3-bind-yjs for the current state of this setup.
Editing Task Contents
We can now synchronize the addition of tasks, but you may notice that you can’t edit the text field of a task yet. Let’s add editing functionality!
// src/taskStore.ts
import { nanoid } from "nanoid";
import { proxy, useSnapshot } from "valtio";
import type { Task, TaskStatus } from "./types";
interface TaskStore {
[taskId: string]: Task;
}
export const filteredTasks = (status: TaskStatus, taskStore: TaskStore): Task[] =>
Object.values(taskStore).filter((task) => task.status === status);
export const taskStore = proxy<TaskStore>({});
export const useTasks = () => useSnapshot(taskStore);
export const addTask = (status: TaskStatus) => {
const order = 0; // dummy
const id = nanoid();
taskStore[id] = {
id,
status,
value: "",
order,
};
};
+export const updateTask = (id: string, value: string) => {
+ const task = taskStore[id];
+ if (task) {
+ task.value = value;
+ }
+};
// src/TaskItem.tsx
-import type { FC } from "react";
+import { type ChangeEvent, type FC, useCallback } from "react";
import styles from "./TaskItem.module.css";
+import { updateTask } from "./taskStore";
import type { Task } from "./types";
interface Props {
task: Task;
}
export const TaskItem: FC<Props> = ({ task }) => {
+ const handleChange = useCallback(
+ (event: ChangeEvent<HTMLInputElement>) => {
+ updateTask(task.id, event.target.value);
+ },
+ [task],
+ );
return (
<li className={styles.listitem}>
<button type="button" className={styles.button}>
<svg
width="24"
height="24"
viewBox="6 0 12 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>Drag</title>
<circle cx="9" cy="12" r="1" />
<circle cx="9" cy="5" r="1" />
<circle cx="9" cy="19" r="1" />
<circle cx="15" cy="12" r="1" />
<circle cx="15" cy="5" r="1" />
<circle cx="15" cy="19" r="1" />
</svg>
</button>
- <input className={styles.input} value={task.value} />
+ <input className={styles.input} value={task.value} onChange={handleChange} />
</li>
);
};
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.
Check section-4-update-task to see the current state.
Reordering Tasks with Drag & Drop
Let’s implement a more complex feature: reordering tasks by drag-and-drop.
First, we’ll implement a way to compute the order
field that we added to the Task
type earlier:
// src/taskStore.ts
import { proxy, useSnapshot } from "valtio";
import type { Task, TaskStatus } from "./types";
import { nanoid } from "nanoid";
interface TaskStore {
[taskId: string]: Task;
}
+const computeOrder = (prevId?: string, nextId?: string): number => {
+ const prevOrder = taskStore[prevId ?? ""]?.order ?? 0;
+ const nextOrder = nextId ? taskStore[nextId].order : 1;
+
+ return (prevOrder + nextOrder) / 2;
+};
export const filteredTasks = (status: TaskStatus, taskStore: TaskStore) =>
- Object.values(taskStore).filter((task) => task.status === status)
+ Object.values(taskStore)
+ .filter((task) => task.status === status)
+ .sort((a, b) => a.order - b.order);
export const taskStore = proxy<TaskStore>({});
export const useTasks = () => useSnapshot(taskStore);
export const addTask = (status: TaskStatus) => {
- const order = 0; // dummy
+ const tasks = filteredTasks(status, taskStore);
+ const lastTask = tasks[tasks.length - 1];
+ const order = computeOrder(lastTask?.id)
const id = nanoid();
taskStore[id] = {
id,
status,
value: "",
order,
};
};
export const updateTask = (id: string, value: string) => {
const task = taskStore[id];
if (task) {
task.value = value;
}
};
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 that0 < 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()
:
// src/taskStore.ts
import { proxy, useSnapshot } from "valtio";
import type { Task, TaskStatus } from "./types";
import { nanoid } from "nanoid";
interface TaskStore {
[taskId: string]: Task;
}
const computeOrder = (prevId?: string, nextId?: string): number => {
const prevOrder = taskStore[prevId ?? ""]?.order ?? 0;
const nextOrder = nextId ? taskStore[nextId].order : 1;
return (prevOrder + nextOrder) / 2;
};
export const filteredTasks = (status: TaskStatus, taskStore: TaskStore): Task[] =>
Object.values(taskStore)
.filter((task) => task.status === status)
.sort((a, b) => a.order - b.order);
export const taskStore = proxy<TaskStore>({});
export const useTasks = () => useSnapshot(taskStore);
export const addTask = (status: TaskStatus) => {
const tasks = filteredTasks(status, taskStore);
const lastTask = tasks[tasks.length - 1];
const order = computeOrder(lastTask?.id)
const id = nanoid();
taskStore[id] = {
id,
status,
value: "",
order,
};
};
export const updateTask = (id: string, value: string) => {
const task = taskStore[id];
if (task) {
task.value = value;
}
};
+
+export const moveTask = (id: string, status: TaskStatus, prevId?: string, nextId?: string) => {
+ const order = computeOrder(prevId, nextId);
+ const task = taskStore[id];
+ if (task) {
+ task.status = status;
+ task.order = order;
+ }
+};
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.
// src/dnd/DndProvider.tsx
import { DndContext, type DragEndEvent } from "@dnd-kit/core";
import { type FC, type PropsWithChildren, useCallback } from "react";
import { moveTask } from "../taskStore";
export const DndProvider: FC<PropsWithChildren> = ({ children }) => {
const handleDragEnd = useCallback((event: DragEndEvent) => {
if (!event.over) {
return;
}
const data = event.over.data.current;
if (!data?.status) {
return;
}
moveTask(String(event.active.id), data.status, data?.prevId, data?.nextId);
}, []);
return <DndContext onDragEnd={handleDragEnd}>{children}</DndContext>;
};
Next, we’ll enable dragging in TaskItem
using useDraggable.
// src/TaskItem.tsx
+import { useDraggable } from "@dnd-kit/core";
+import { CSS } from "@dnd-kit/utilities";
import { type ChangeEvent, type FC, useCallback } from "react";
import styles from "./TaskItem.module.css";
import { updateTask } from "./taskStore";
import type { Task } from "./types";
interface Props {
task: Task;
}
export const TaskItem: FC<Props> = ({ task }) => {
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
+ id: task.id,
+ });
+ const style = {
+ transform: CSS.Translate.toString(transform),
+ };
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
updateTask(task.id, event.target.value);
},
[task],
);
return (
- <li className={styles.listitem}>
+ <li
+ className={`${styles.listitem} ${isDragging ? styles.isDragging : ""}`}
+ ref={setNodeRef}
+ style={style}
+ >
- <button type="button" className={styles.button}>
+ <button type="button" className={styles.button} {...listeners} {...attributes}>
<svg
width="24"
height="24"
viewBox="6 0 12 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>Drag</title>
<circle cx="9" cy="12" r="1" />
<circle cx="9" cy="5" r="1" />
<circle cx="9" cy="19" r="1" />
<circle cx="15" cy="12" r="1" />
<circle cx="15" cy="5" r="1" />
<circle cx="15" cy="19" r="1" />
</svg>
</button>
<input className={styles.input} value={task.value} onChange={handleChange} />
</li>
);
};
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
:
// src/dnd/DroppableMarker.tsx
import { useDroppable } from "@dnd-kit/core";
import { type FC, useId } from "react";
import type { TaskStatus } from "../types";
import styles from "./DroppableMarker.module.css";
type Props = {
status: TaskStatus;
prevId?: string | undefined;
nextId?: string | undefined;
};
export const DroppableMarker: FC<Props> = ({ status, prevId, nextId }) => {
const id = useId();
const { isOver, setNodeRef } = useDroppable({
id,
data: { status, prevId, nextId },
});
return (
<div
ref={setNodeRef}
className={`${styles.wrapper} ${isOver ? styles.isOver : ""}`}
/>
);
};
We can define arbitrary values in data
, so we store the information needed by moveTask()
here.
Let’s add the styles:
/* src/dnd/DroppableMarker.module.css */
.wrapper {
width: 100%;
height: 2px;
background-color: transparent;
transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.isOver {
background-color: var(--zinc-400);
}
Finally, we’ll place the DroppableMarker
between tasks in TaskColumn
:
// src/TaskColumn.tsx
-import type { FC } from "react";
+import { type FC, Fragment } from "react";
import { TaskAddButton } from "./TaskAddButton";
import styles from "./TaskColumn.module.css";
import { TaskItem } from "./TaskItem";
import { DroppableMarker } from "./dnd/DroppableMarker";
import { filteredTasks, useTasks } from "./taskStore";
import type { TaskStatus } from "./types";
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} />
- ))}
+ <DroppableMarker status={status} nextId={tasks[0]?.id} />
+ {tasks.map((task, index) => (
+ <Fragment key={task.id}>
+ <TaskItem task={task} />
+ <DroppableMarker
+ key={`${task.id}-border`}
+ status={status}
+ prevId={task.id}
+ nextId={tasks[index + 1]?.id}
+ />
+ </Fragment>
+ ))}
</ul>
<TaskAddButton status={status} />
</div>
);
};
You can now reorder tasks by dragging and dropping them!
Check section-5-dnd to see the current state.
Synchronizing Cursor Positions
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 crdt
const awareness = provider.awareness
// You can observe when a user updates their awareness information
awareness.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 displayed
name: 'Emmanuelle Charpentier',
// Define a color that should be associated to the user:
color: '#ffb61e' // should be a hex color
})
(Quoted from https://docs.yjs.dev/getting-started/adding-awareness#quick-start-awareness-crdt)
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:
// src/yjs/useAwareness.ts
import { useSyncExternalStore } from "react";
import { provider } from "./yjs";
type UseAwarenessResult<T> = {
states: Record<number, T>;
localId: number;
setLocalState: (nextState: T) => void;
};
const awareness = provider.awareness;
const subscribe = (callback: () => void) => {
awareness.on("change", callback);
return () => {
awareness.off("change", callback);
};
};
const getSnapshot = () =>
JSON.stringify(Object.fromEntries(awareness.getStates()));
const setLocalState = <T extends {}>(nextState: T) =>
awareness.setLocalState(nextState);
export const useAwareness = <T extends {}>(): UseAwarenessResult<T> => {
const states = JSON.parse(
useSyncExternalStore(subscribe, getSnapshot)
) as Record<number, T>;
return {
states,
localId: awareness.clientID,
setLocalState,
};
};
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/yjs/Cursors.tsx
import { useEffect, useState, type FC } from "react";
import { useAwareness } from "./useAwareness";
import styles from "./Cursors.module.css";
const sample = (arr: string[]) => {
const randomIndex = Math.floor(Math.random() * arr.length);
return arr[randomIndex];
};
// dummy
const names = ["Alice", "Bob", "Charlie", "David", "Eve"];
const colors = ["green", "orange", "magenta", "gold", "fuchsia"];
type MyInfo = {
name: string;
color: string;
};
type CursorState = MyInfo & {
x: number;
y: number;
};
export const Cursors: FC = () => {
const [myInfo] = useState<MyInfo>({
name: sample(names),
color: sample(colors),
});
const { states, localId, setLocalState } = useAwareness<CursorState>();
const cursors = Object.entries(states).filter(
([id]) => id !== String(localId)
);
useEffect(() => {
const update = (event: MouseEvent) => {
setLocalState({
...myInfo,
x: event.clientX,
y: event.clientY,
});
};
window.addEventListener("mousemove", update);
return () => window.removeEventListener("mousemove", update);
}, [setLocalState, myInfo]);
return (
<div className={styles.wrapper}>
{cursors.map(([id, state]) => (
<div
key={id}
className={styles.cursor}
style={{
left: state.x,
top: state.y,
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={styles.svg}
style={{
color: state.color,
}}
>
<title>Cursor</title>
<path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z" />
<path d="m13 13 6 6" />
</svg>
<span
style={{ backgroundColor: state.color }}
className={styles.name}
>
{state.name}
</span>
</div>
))}
</div>
);
};
/* src/yjs/Cursors.module.css */
.wrapper {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
}
.cursor {
position: absolute;
display: flex;
}
.svg {
width: 16px;
height: 16px;
}
.name {
font-size: 0.6rem;
padding: 0.05rem 0.2rem;
margin-top: 1rem;
border-radius: var(--rounded-xs);
}
Finally, import and render Cursors
in App.tsx
:
// 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!
You can see this in section-6-sync-cursors.
In Conclusion
You’ve now built a practical collaborative editing application using Yjs. Here are some suggestions if you’d like to expand further:
- Add a login function and display the user’s actual name (from login) in the cursor
- Create multiple Kanban boards
- Persist Yjs documents to Redis or Amazon S3
If you notice any issues in this tutorial, feel free to open a ticket at:
https://github.com/route06/yjs-kanban-tutorial/issues/new
Top comments (2)
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.
Hey Edwin.
Oh, I didn't notice the valtio update. I'll update the article and repository right away! Thanks!