チュートリアル: Yjs, valtio, React で実現する共同編集アプリケーション

Yjsは、リアルタイム共同編集を実現するためのアルゴリズムとデータ構造を提供するフレームワークです。Notion や Figma のように、1 つのコンテンツを複数人で同時に更新する体験を提供することができます。
Y.Map, Y.Array, Y.Text といった共有データ型を提供し、それらは JavaScript の Map や Array のように利用できます。さらにそのデータに対する変更は他のクライアントに自動的に配布・同期されます。
Yjs は Conflict-free Replicated Data Types (CRDT) と呼ばれるアルゴリズムの実装であり、複数人が同時にデータを操作してもコンフリクトが発生せず、最終的に全てのクライアントが同じ状態に到達するように設計されています。

クイックスタート

Y.Map がクライアント間で自動的に同期されるコード例を見てみましょう。

import * as Y from "yjs";

// Yjsドキュメントは、自動的に同期する共有オブジェクトのコレクションです。
const ydoc = new Y.Doc();
// 共有型であるY.Mapインスタンスを定義します。
const ymap = ydoc.getMap();
ymap.set("keyA", "valueA");

// 別のYjsドキュメントを作成します(リモートユーザーのシミュレートです)。
// そしてコンフリクトするような変更を追加します。
const ydocRemote = new Y.Doc();
const ymapRemote = ydocRemote.getMap();
ymapRemote.set("keyB", "valueB");

// リモートからの変更をマージします。
const update = Y.encodeStateAsUpdate(ydocRemote);
Y.applyUpdate(ydoc, update);

// 変更がマージされたことを確認します。
console.log(ymap.toJSON()); // => { keyA: 'valueA', keyB: 'valueB' }

( https://docs.yjs.dev/#quick-start から引用し、コメント部分は筆者による翻訳)

中心となるのが Y.Doc(Yjs ドキュメント)です。Y.Doc は複数の共有データ型を含み、他のクライアントとの同期を管理します。クライアントセッションごとに Y.Doc を作成し、それぞれが固有の clientID を持ちます。

プロバイダー

上記の例では同じ端末で複数ユーザーをシミュレートしましたが、実際にネットワークを介して変更を同期するにはプロバイダーを利用します。Yjs は特定のネットワークプロトコルに依存しておらず、プロバイダーを柔軟に切り替えたり、複数のプロバイダーを併用することができます。以下にいくつかのプロバイダーを紹介します:

  • y-websocket: WebSocket で送受信を行うクライアントサーバーモデルを実現します。サーバーで Yjs ドキュメントを永続化したり、リクエスト認証を行う際に有用です。
  • y-webrtc: WebRTC でピアツーピアでの同期を行います。これにより分散アプリケーションを実現できます。
  • y-indexeddb: IndexedDB データベースを利用して、共有データをブラウザに保存します。これによりオフライン編集が可能になります。

その他のプロバイダーについては、公式ドキュメントをご覧ください:

docs.yjs.dev

エディタバインディング

構築するアプリケーションがテキストエディタの場合、ProseMirror や Quill などのエディタフレームワークを利用することが多いでしょう。Yjs は一般的なエディタフレームワークをサポートしており、プラグインや拡張機能として利用できます。一般的なユースケースであれば Yjs の共有データ型を直接操作する必要はありません。
Yjs がサポートしているエディタフレームワークの一覧は以下をご覧ください:

docs.yjs.dev

そのため、これらのエディタフレームワークを利用する場合は、対応するプラグインを利用するのが良いでしょう。しかし、Figma のようなデザインエディタなど複雑な GUI を提供するアプリケーションの場合は、ゼロからスクラッチでエディタ UI を構築することも少なくありません。

今回はエディタバインディングを使わない例として、UI ライブラリに React を利用し、Yjs と接続した共同編集アプリケーションを構築するチュートリアルを紹介します。

デモ

このチュートリアルではカンバン形式のタスク管理アプリを構築します。まず最終的な成果物を紹介します。

以下の機能を実装します:

  • タスクの追加と編集
  • To do / In Progress / Done のステータス管理
  • ドラッグ&ドロップによる並び替え
  • 複数人で同時に操作が可能で、リアルタイムに反映
  • 参加している他のユーザーのカーソル表示

構築するアプリケーションの全体像は以下のリポジトリで確認できます:

github.com

Yjs と React の中間層として valtio を利用

今回はエディタバインディングを使用しないため、Yjs の共有データ型を自身で操作することになります。しかし React コンポーネントから直接共有データ型にアクセスすると、Yjs と密結合となりテストが困難になるほか、React の状態管理と別のデータフローが混在してバグを引き起こしやすくなります。

そこで、状態管理ライブラリとしてvaltioを利用します。valtio はプロキシベースのシンプルなライブラリで、valtio-yjsというライブラリと組み合わせることで valtio の状態を Yjs の共有データ型に同期できます。これにより、React コンポーネントは valtio の状態を介して Yjs のデータと連携でき、状態管理が容易になります。

その他の利用ライブラリ

このチュートリアルでは、他にも以下のライブラリを使用します:

  • TypeScript: JavaScript のスーパーセットで、静的型付けをサポートします。コードの品質を向上させ、開発中のエラーを減少させることができます。
  • Vite: 高速な開発環境を提供するビルドツール。軽量で使いやすく、ホットリロード機能を備えています。
  • CSS Modules: スコープ付きの CSS を実現するための仕組み。コンポーネントごとにスタイルを管理し、スタイルの衝突を避けることができます。
  • nanoid: 高速なユニーク ID 生成ライブラリ。今回はタスクの ID 生成に利用します。

プロジェクトのセットアップ

事前の説明が長くなりました。早速始めて行きましょう! Vite の React と TypeScript のテンプレートを使用し、 yjs-kanban-tutorial という名前の新しいプロジェクトを作成します。

npm create vite@latest yjs-kanban-tutorial -- --template react-ts

最初にタスクの型定義を用意します。今回タスクはステータス・値・順序のフィールドを持ちます。

// src/types.ts
export type TaskStatus = "To Do" | "In Progress" | "Done";

export interface Task {
  id: string;
  status: TaskStatus;
  value: string;
  order: number;
}

まずは見た目部分を一気に作ってしまいましょう。以下のコンポーネントをそれぞれ追加します。

  • TaskAddButton
  • TaskItem
  • TaskColumn

まだスタイリングだけのため、これらのコードは詳しく説明しません。コードをコピー・ペーストしてください。

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;
}

続いて App.tsx, main.tsx, App.css, index.css を以下の内容に差し替えます。既存のコードは消してしまってください。

// 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;
}

App.cssApp.module.css に書き換えていることに注意してください。

ここまで追加したところで npm run dev を実行すると、以下のように表示されます。

ここまでの内容はsection-1-setup-projectブランチで確認できます。

タスクの追加機能の実装

タスクを追加できるようにしましょう。状態管理のための valtio と、id 採番のための nanoid を依存に追加します。

npm install valtio nanoid

valtio を使ってタスクの状態管理を行います。 以下のように taskStore.ts を追加します。

// 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);

TaskStore 型では、taskId をキー・Task を値としたオブジェクトを定義しています。タスクの配列を返す filteredTasks() 関数も用意しました。
「なぜ TaskStore でタスクの配列として定義しないのか?」と疑問に思った方もいるでしょう。これはタスクの検索・変更を容易にする目的なのですが、後ほど説明します。

proxy()は valtio の基礎となる関数で、渡されたオブジェクトの変更を追跡するプロキシオブジェクトを作成します。
useSnapshot()は React コンポーネントから valtio のプロキシオブジェクトを利用するためのカスタムフックで、読み取り専用のスナップショットを取り出せます。オブジェクトが変更された時に Valtio は React コンポーネントを再レンダリングします。

使い方を見ていきながら、機能を追加していきましょう。まずは useSnapshot() を使ってタスクの表示ができるようにします。taskColumn.tsx で 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>
  );
};

ダミーデータを置き換え、useSnapshot でデータを取得するようにしました。 ただ taskStore にはデータがないため、このままでは空の表示しかできません。タスクの追加ができるようにしましょう。

// 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,
+  };
+};

addTask() 関数を追加しました。order はドラッグ&ドロップの実装時に計算するので、今はダミー値を入れておきます。

続いてこの addTask() を TaskAddButton で使うようにしましょう。

// 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>
  );
};

この状態で画面上の + Add を押すと、タスクの追加ができるようになりました。

valtio を使う際は、以下の二点を押さえておくと良いでしょう。

  • データの表示 ... useSnapshot() で読み取り専用のオブジェクト経由で取得する
  • データの変更 ... proxy() で作成したプロキシオブジェクトを直接変更する

ここまでの内容は section-2-add-task ブランチで確認できます。

複数クライアント間でのデータ同期

まだタスクを追加できるようになっただけですが、この時点からでも共同編集は始められます。複数クライアント間でデータ同期をできるようにしましょう。必要なライブラリをインストールします。

npm install yjs valtio-yjs@0.5.1 y-websocket

Warning: valtio-yjs の最新バージョンである v0.6.0 は、valtio のメジャーバージョンである v2.0.0-rc.0 以上と指定されているようです。こちらはまだ rc 版のため、このチュートリアルでは v0.5.1 を利用します。

Yjs でネットワークを介してデータの同期をするにはプロバイダーが必要と説明しましたが、今回は y-websocket を利用します。クライアントは Websocket を介して単一のエンドポイントに接続します。y-websocket にはサーバーが付属されており、簡易的なインメモリデータベースも含まれているため、データの永続化も可能です。

まず websocket サーバーを立ち上げられるようにします。package.json に npm scripts を追加しましょう。

// package.json
{
  "scripts": {
    "dev-websocket": "y-websocket --port 1234"
  }
}

そして 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

Yjs ドキュメントなどを保持する yjs/yjs.ts と、taskStore を yjs 経由で同期する yjs/useSyncToYjsEffect() フックを作成します。

// 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();
    };
  }, []);
};

詳しく見ていきましょう。Y.Doc インスタンスに紐づく、"taskStore.v1" という名前の Y.Map を定義しています。こちらは任意の名前をつけることができます。

WebsocketProvider は、接続するエンドポイント、ルーム名、Y.Doc の順で渡しています。ルーム名も任意の名前を付与できますが、一般的なアプリケーションでは複数のルームを作りユーザーは入るルームを選択するはずで、ID のような識別可能な値を指定することが多いでしょう。

useSyncToYjsEffect() の useEffect の中では valtio-yjs の bind() 関数を呼び出し、valtio のプロキシオブジェクトである taskStore と ymap を渡しています。 これにより、自身の画面上での操作は taskStore → Y.Map → Websocket のように他のクライアントへと送信され、他のクライアントからの変更は Websocket → Y.Map → taskStore のように自身の画面へと反映されることとなります。

この useSyncToYjsEffect() を 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;

この状態でブラウザのタブを 2 つ開き、画面上でタスクを追加すると、もう片方の画面にもリアルタイムで反映できることがわかります!

ここまでの内容は、section-3-bind-yjsブランチで確認できます。

タスク内容の編集

データの追加は同期できましたが、タスク項目のテキストフィールドを編集できないこと気づくでしょう。編集機能を追加しましょう!

// 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>
  );
};

これで編集したテキストも保存され、Yjs を介して同期されるようになりました。

タスクの編集には編集対象のタスクの検索が必要ですが、データ構造としてオブジェクトで管理しているため taskStore[id] で簡単に取得できます。配列で定義していた場合は taskStore.find(task => task.id === id) のように検索する必要がありました。
編集方法も task.name = name のようにプロパティを直接変更しており、Reactにおける状態管理では状態を直接変更することはあまりないため違和感を持つかもしれません。これは valtio のルールという理由もありますが、意図があります。
Yjs の共有データ型に加わる変更は全てトランザクションという単位にまとめられ、自身の変更を行うと同時に他のクライアントにも送信します。送信するメッセージ量を抑えるため、変更の範囲は可能な限り小さくすることが重要です。

ここまでの内容はsection-4-update-taskで確認できます。

ドラッグ&ドロップでタスクを並び替える

少し複雑な機能として、ドラッグ&ドロップによる並び替えを実装しましょう。
まず Task 型に追加していた order フィールドの計算を実装します。

// 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;
  }
};

リストの並び替えの実現方法はいくつかありますが、今回は簡易的な Fractional Indexing を実装します。簡単に説明すると以下の二点となります:

  • 全ての order の値は 0 < index < 1の小数とする
  • 配置したい位置の前後の order の平均値を計算する

これらを実装したのが computeOrder() となります。
Fractional Indexing は既存要素のインデックスを変更せずに位置を指定できることがメリットですが、並び替えを何度も繰り返すと浮動小数点数の限界に近づいていき衝突が発生してしまいます。その場合全順序の再計算が必要になりますが、今回は簡単にするため実装しません。

addTask() での order の計算を後回しにしていたので、ここで実装しました。タスクの追加は末尾に行うことにするため、末尾タスクの order と 1 の平均がタスク追加時の index となります。

続いて、 computeOrder() を使ってタスクを移動する moveTask() を実装します。

// 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;
+  }
+};

ドラッグ&ドロップの実装には dnd kitを利用します。依存に追加しましょう。

npm install @dnd-kit/core

@dnd-kit/core の使い方は少々省略しながら進めます。まずは DndContextonDragEnd でハンドラ関数を実装します。ここで moveTask()を呼び出し、ドロップした場所にタスクを移動するようにします。

// 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>;
};

続いて useDraggable を利用し TaskItem をドラッグできるようにしましょう。

// 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>
  );
};

続いてuseDroppableを使ってドロップ可能な場所を教える必要がありますが、今回はタスクとタスクの間に目印となるマーカーを表示することにします。 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 : ""}`}
    />
  );
};

data には任意の値を入れることができるので、 moveTask() に必要なデータをここで定義しました。 スタイルも合わせて追加します。

/* 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);
}

最後に、 TaskColumn でタスクの間に DroppableMarker を配置しましょう。

// 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>
  );
};

これでタスクの並び替えもできるようになりました!

ここまでの内容はsection-5-dndブランチで確認できます。

カーソル位置の同期

今まではコンテンツの同期を中心に共同編集機能を実装してきましたが、「今、誰が、どこで作業しているか?」という他ユーザーの存在を伝える情報も共同編集における重要な要素です。具体的にはカーソル情報やユーザーアイコンの表示などが挙げられます。
これらの情報は編集中のみ必要で永続化する必要がないため、Yjs ではAwareness CRDTと呼ばれる共有データ型とは別の機能として提供されています。コード例を見てみましょう。

// 全てのネットワークプロバイダーは、Awareness CRDT を実装しています。
const awareness = provider.awareness;

// 他のユーザーが自分の awareness 情報を更新したときに、通知を受け取ることができます。
awareness.on("change", (changes) => {
  // 誰かが Awareness の情報を更新するたびに、すべてのユーザーからのすべての Awareness の情報を記録します。
  console.log(Array.from(awareness.getStates().values()));
});

// Awareness の情報はキーバリューストアと考えることができます。user フィールドを更新して、関連するユーザー情報を伝播します。
awareness.setLocalStateField("user", {
  // 表示する名前を定義します。
  name: "Emmanuelle Charpentier",
  // ユーザーに関連付ける色を定義します:
  color: "#ffb61e", // 16進数のカラーコード
});

(https://docs.yjs.dev/getting-started/adding-awareness#quick-start-awareness-crdt より引用し、コメント部分は筆者による翻訳)

awareness.setLocalStateField() で行なっているように、Awareness CRDT では JSON でエンコード可能な任意のデータを保存することができます。ここではユーザー名とカラーコードを保存していますが、他にもユーザーのカーソル位置やテキストの選択範囲、アイコン画像などを保存することが考えられます。

今回は Awareness CRDT を使ってカーソル位置を同期する機能を実装しましょう。まずは 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,
  };
};

Yjs ドキュメントでは中間層として valtio を利用しましたが、 valtio-yjs に Awareness CRDT との接続機能がないため、React の標準機能である useSyncExternalStore() を利用し同期することにしました。
useSyncExternalStore() は 外部のデータストアを参照しつつ、値が変化した際にコンポーネントを再レンダリングしたい場合に利用します。再レンダリングの判断には getSnapshot() で取得した値を Object.is() で比較することで行ないますが、 awareness.getStates() は Map オブジェクトを返すため、JSON 文字列に変換してから比較します。

このカスタムフックを使い、カーソル情報を保存・表示する Cursors コンポーネントを作成します。

// 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);
}
// 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;

mousemove イベントでカーソルの位置を更新し、Awareness CRDT に保存します。また、自分のカーソル情報は states から除外して表示します。
これで他ユーザーのカーソル位置がリアルタイムで表示されるようになりました!

ここまでの内容はsection-6-sync-cursorsブランチで確認できます。

最後に

Yjs を利用した、実践的な共同編集アプリケーションを完成できました。さらに先に進みたい場合は、以下のような機能を追加してみると良いでしょう。

  • ログイン機能の追加と、カーソルに表示する名前をログインユーザー名に変更
  • 複数カンバンボードの作成機能
  • Redis や Amazon S3 などへの Yjs ドキュメントの永続化

チュートリアルの内容に不備があった場合、 https://github.com/route06/yjs-kanban-tutorial/issues/new でお知らせ下さると幸いです。

ROUTE06 ではローコード開発プラットフォームの開発を行なっており、Yjs を利用した共同編集機能も提供しています。Yjs を利用した複雑なアプリケーションの開発に興味があるエンジニアを募集しています。詳しくは以下をご覧ください。

https://jobs.route06.co.jp/