取り組みの背景:「一緒に作る」体験をアプリに組み込む
Google ドキュメントやFigmaのように「複数人が同時に編集できる」機能は、現代のプロダクティビティアプリには欠かせない要素になりつつあります。しかし、このリアルタイム協調編集(Collaborative Editing)の実装は、従来の開発では非常に難易度が高い領域でしました。
Rork MaxとLiveblocks・Yjsを組み合わせることで、この複雑な機能をモバイルアプリに比較的シンプルに組み込むことが可能です。ここで扱うのはCRDTの基本概念から始め、実際に動作するリアルタイム協調編集アプリをステップバイステップで構築する方法を解説します。
対象読者は、Rork Maxの基本的な使い方を理解しており、React Native / Expoでの開発経験がある方です。バックエンドの知識(Supabase or Firebase)があるとさらに理解が深まります。
CRDT とは何か:競合を「自動解決」する仕組み
リアルタイム協調編集の根幹にあるのが、**CRDT(Conflict-free Replicated Data Type:競合なし複製データ型)**という概念です。
従来のリアルタイム同期(OT: Operational Transformation)はGoogleが開発した手法で強力ですが、実装が複雑でサーバーが単一障害点になるリスクがありました。これに対してCRDTは:
- 数学的に証明された競合解決 — どの順序でオペレーションが届いても、最終的に全クライアントが同じ状態に収束する
- オフラインファースト対応 — ネットワーク切断中も編集を続けられ、再接続時に自動マージ
- サーバー不要の分散設計 — P2Pで同期することも可能
YjsはJavaScript向けのCRDT実装ライブラリで、以下のデータ型を提供します:
Y.Text — テキストの協調編集(差分挿入・削除)
Y.Array — 配列の協調操作
Y.Map — オブジェクトの協調更新
Y.XmlFragment — リッチテキスト・HTMLの協調編集
Liveblocksは、YjsのホスティングインフラとリアルタイムAPIを提供するSaaS。WebSocketサーバーの管理・スケーリングを肩代わりしてくれるため、アプリ側の実装に集中できます。
アーキテクチャ設計:何をどこで管理するか
本記事で構築するシステムの全体像です:
[ユーザーA] ←── Liveblocks Realtime ──→ [ユーザーB]
│ │ │
Yjs Doc WebSocket Yjs Doc
│ (Awareness) │
└── Supabase DB (永続化) ──────────────┘
技術選定の理由:
- Liveblocks Presence — 誰が今オンラインか、カーソル位置はどこか(アウェアネス情報)
- Yjs via Liveblocks — ドキュメントの状態をCRDTで同期(コンフリクトゼロ)
- Supabase Realtime — チャンネル通知・メタデータ(タイトル、タグ、権限)の管理
- Supabase Storage — 添付ファイル・画像の保存
Step 1: Liveblocksアカウントとプロジェクト設定
まずLiveblocksでアカウントを作成し、新しいプロジェクトを作成します。無料プランでも月間50接続・月間5,000 MAUまで利用できます。
Rork Maxのターミナルで以下を実行します:
# Yjs + Liveblocks SDKのインストール
npx expo install yjs @liveblocks/client @liveblocks/react
# WebSocketプロバイダー(Liveblocks対応)
npx expo install @liveblocks/yjs
環境変数の設定(.env.local):
EXPO_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_live_YOUR_PUBLIC_KEY_HERE
注意: EXPO_PUBLIC_ プレフィックスをつけることで、Expo クライアントにキーが公開されます。シークレットキー(sk_live_...)は絶対にクライアントに含めないでください。
Step 2: Liveblocksクライアントの初期化
src/lib/liveblocks.ts を作成します:
import { createClient } from "@liveblocks/client";
import { createRoomContext } from "@liveblocks/react";
// Liveblocksクライアントの初期化
const client = createClient({
publicApiKey: process.env.EXPO_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!,
// アウェアネス情報のスロットリング(100ms)
throttle: 100,
});
// Presence型の定義(各ユーザーの状態)
type Presence = {
cursor: { x: number; y: number } | null;
username: string;
color: string; // ユーザーカラー(ランダム割り当て)
selectedNodeId: string | null; // 現在選択中の要素
};
// Storage型(CRDTで同期するデータ)
type Storage = {
// Yjsのドキュメントはストレージではなく別チャンネルで管理
documentTitle: string;
collaborators: string[];
};
// イベント型(ブロードキャスト)
type UserMeta = {
id: string;
info: {
name: string;
avatar?: string;
};
};
type RoomEvent =
| { type: "SELECTION_CHANGE"; nodeId: string }
| { type: "COMMENT_ADDED"; commentId: string };
// React向けのコンテキスト作成
export const {
RoomProvider,
useRoom,
useMyPresence,
useOthers,
useOther,
useBroadcastEvent,
useEventListener,
useStorage,
useMutation,
} = createRoomContext<Presence, Storage, UserMeta, RoomEvent>(client);
Step 3: YjsドキュメントとLiveblocksの接続
src/hooks/useCollaborativeDoc.ts を作成します:
import { useEffect, useRef, useState } from "react";
import * as Y from "yjs";
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import { useRoom } from "../lib/liveblocks";
interface UseCollaborativeDocOptions {
documentId: string;
}
export function useCollaborativeDoc({ documentId }: UseCollaborativeDocOptions) {
const room = useRoom();
const [isConnected, setIsConnected] = useState(false);
const [isSynced, setIsSynced] = useState(false);
// Yjsドキュメントの初期化(ref で安定参照)
const ydocRef = useRef<Y.Doc>(new Y.Doc());
const providerRef = useRef<LiveblocksYjsProvider | null>(null);
useEffect(() => {
const ydoc = ydocRef.current;
// LiveblocksYjsProvider — Yjsの変更をLiveblocksサーバーと同期
const provider = new LiveblocksYjsProvider(room, ydoc);
providerRef.current = provider;
// 接続状態の監視
provider.on("sync", (synced: boolean) => {
setIsSynced(synced);
});
provider.on("status", ({ status }: { status: string }) => {
setIsConnected(status === "connected");
});
return () => {
provider.destroy();
providerRef.current = null;
};
}, [room]);
// Yjsテキスト型(メインコンテンツ)
const yText = ydocRef.current.getText("content");
// Yjsマップ型(メタデータ)
const yMeta = ydocRef.current.getMap("metadata");
return {
ydoc: ydocRef.current,
provider: providerRef.current,
yText,
yMeta,
isConnected,
isSynced,
};
}
Step 4: プレゼンス表示(オンラインユーザー一覧)
誰が今ドキュメントを開いているかを表示するコンポーネントです:
// src/components/CollaboratorAvatars.tsx
import React from "react";
import { View, Text, StyleSheet } from "react-native";
import { useOthers, useMyPresence } from "../lib/liveblocks";
// ユーザーカラーのプリセット
const USER_COLORS = [
"#E57373", "#F06292", "#BA68C8", "#7986CB",
"#4FC3F7", "#4DB6AC", "#81C784", "#FFD54F",
];
function getColorForUser(userId: string): string {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = userId.charCodeAt(i) + ((hash << 5) - hash);
}
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
}
export function CollaboratorAvatars() {
const others = useOthers();
const [myPresence] = useMyPresence();
return (
<View style={styles.container}>
{/* 自分のアバター */}
<View style={[styles.avatar, { backgroundColor: "#4CAF50" }]}>
<Text style={styles.initial}>You</Text>
</View>
{/* 他のユーザー(最大5人表示) */}
{others.slice(0, 5).map((other) => (
<View
key={other.connectionId}
style={[
styles.avatar,
{ backgroundColor: getColorForUser(String(other.connectionId)) },
]}
>
<Text style={styles.initial}>
{other.presence.username?.charAt(0).toUpperCase() ?? "?"}
</Text>
</View>
))}
{/* 6人以上の場合 */}
{others.length > 5 && (
<View style={[styles.avatar, styles.overflowAvatar]}>
<Text style={styles.initial}>+{others.length - 5}</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
gap: -8, // 重なって表示
},
avatar: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "#FFFFFF",
},
overflowAvatar: {
backgroundColor: "#9E9E9E",
},
initial: {
color: "#FFFFFF",
fontSize: 11,
fontWeight: "700",
},
});
Step 5: 協調テキストエディタの実装
Rork Maxでは、TextInputとYjsを組み合わせてリアルタイム同期テキストエディタを構築できます:
// src/components/CollaborativeTextEditor.tsx
import React, { useEffect, useState, useCallback } from "react";
import {
TextInput,
View,
Text,
StyleSheet,
ActivityIndicator,
} from "react-native";
import * as Y from "yjs";
interface CollaborativeTextEditorProps {
yText: Y.Text;
isConnected: boolean;
isSynced: boolean;
placeholder?: string;
}
export function CollaborativeTextEditor({
yText,
isConnected,
isSynced,
placeholder = "ここに入力してください...",
}: CollaborativeTextEditorProps) {
const [content, setContent] = useState("");
const [isLocalChange, setIsLocalChange] = useState(false);
// Yjsドキュメントの変更を購読
useEffect(() => {
const handleChange = (events: Y.YTextEvent[], transaction: Y.Transaction) => {
// ローカル操作による変更は無視(二重更新を防ぐ)
if (transaction.local) return;
setContent(yText.toString());
};
// 初期テキストを読み込む
setContent(yText.toString());
yText.observe(handleChange);
return () => yText.unobserve(handleChange);
}, [yText]);
// ユーザーの入力をYjsドキュメントに反映
const handleTextChange = useCallback(
(newText: string) => {
setIsLocalChange(true);
// 差分計算(変更箇所のみをYjsに適用)
const oldText = yText.toString();
// シンプルな差分アルゴリズム
// 本番では myers diff など高速なアルゴリズムを使用
let commonPrefixLen = 0;
while (
commonPrefixLen < oldText.length &&
commonPrefixLen < newText.length &&
oldText[commonPrefixLen] === newText[commonPrefixLen]
) {
commonPrefixLen++;
}
let commonSuffixLen = 0;
while (
commonSuffixLen < oldText.length - commonPrefixLen &&
commonSuffixLen < newText.length - commonPrefixLen &&
oldText[oldText.length - 1 - commonSuffixLen] ===
newText[newText.length - 1 - commonSuffixLen]
) {
commonSuffixLen++;
}
const deleteCount =
oldText.length - commonPrefixLen - commonSuffixLen;
const insertText = newText.slice(
commonPrefixLen,
newText.length - commonSuffixLen
);
// Yjs トランザクションとして適用(アトミック操作)
yText.doc?.transact(() => {
if (deleteCount > 0) {
yText.delete(commonPrefixLen, deleteCount);
}
if (insertText.length > 0) {
yText.insert(commonPrefixLen, insertText);
}
});
setContent(newText);
setIsLocalChange(false);
},
[yText]
);
return (
<View style={styles.container}>
{/* 接続状態インジケーター */}
<View style={styles.statusBar}>
{!isConnected ? (
<View style={styles.statusIndicator}>
<View style={[styles.dot, styles.dotOffline]} />
<Text style={styles.statusText}>オフライン(自動再接続中...)</Text>
</View>
) : !isSynced ? (
<View style={styles.statusIndicator}>
<ActivityIndicator size="small" color="#FF9800" />
<Text style={styles.statusText}>同期中...</Text>
</View>
) : (
<View style={styles.statusIndicator}>
<View style={[styles.dot, styles.dotOnline]} />
<Text style={styles.statusText}>リアルタイム同期中</Text>
</View>
)}
</View>
{/* メインテキストエディタ */}
<TextInput
style={styles.editor}
multiline
value={content}
onChangeText={handleTextChange}
placeholder={placeholder}
placeholderTextColor="#9E9E9E"
textAlignVertical="top"
scrollEnabled
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#FFFFFF",
},
statusBar: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: "#F5F5F5",
borderBottomWidth: 1,
borderBottomColor: "#E0E0E0",
},
statusIndicator: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
dotOnline: {
backgroundColor: "#4CAF50",
},
dotOffline: {
backgroundColor: "#F44336",
},
statusText: {
fontSize: 12,
color: "#666666",
},
editor: {
flex: 1,
padding: 16,
fontSize: 16,
lineHeight: 24,
color: "#212121",
fontFamily: "System",
},
});
Step 6: ルームプロバイダーとメイン画面の組み立て
// src/app/document/[id].tsx
import React from "react";
import { View, StyleSheet } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { RoomProvider } from "../../lib/liveblocks";
import { CollaborativeDocumentScreen } from "../../components/CollaborativeDocumentScreen";
// 疑似乱数でユーザーカラーを生成
function generateRandomColor(): string {
const colors = ["#E57373", "#64B5F6", "#81C784", "#FFD54F", "#BA68C8"];
return colors[Math.floor(Math.random() * colors.length)];
}
export default function DocumentPage() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<RoomProvider
id={`document-${id}`}
initialPresence={{
cursor: null,
username: "ユーザー", // 実際はAuthから取得
color: generateRandomColor(),
selectedNodeId: null,
}}
initialStorage={{
documentTitle: "新規ドキュメント",
collaborators: [],
}}
>
<View style={styles.container}>
<CollaborativeDocumentScreen documentId={id} />
</View>
</RoomProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#FFFFFF",
},
});
// src/components/CollaborativeDocumentScreen.tsx
import React from "react";
import { View, StyleSheet, SafeAreaView } from "react-native";
import { useRoom } from "../lib/liveblocks";
import { useCollaborativeDoc } from "../hooks/useCollaborativeDoc";
import { CollaborativeTextEditor } from "./CollaborativeTextEditor";
import { CollaboratorAvatars } from "./CollaboratorAvatars";
import { DocumentHeader } from "./DocumentHeader";
interface Props {
documentId: string;
}
export function CollaborativeDocumentScreen({ documentId }: Props) {
const room = useRoom();
const { yText, isConnected, isSynced } = useCollaborativeDoc({
documentId,
});
return (
<SafeAreaView style={styles.container}>
{/* ヘッダー:タイトル + コラボレーターアバター */}
<View style={styles.header}>
<DocumentHeader documentId={documentId} />
<CollaboratorAvatars />
</View>
{/* メインエディタ */}
<CollaborativeTextEditor
yText={yText}
isConnected={isConnected}
isSynced={isSynced}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#E0E0E0",
},
});
Step 7: Supabaseによる永続化とドキュメント管理
Liveblocksはリアルタイム同期を担当しますが、ドキュメントの永続的な管理(作成・削除・共有権限)にはSupabaseを使います。
Supabaseテーブル設計:
-- ドキュメントテーブル
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL DEFAULT '新規ドキュメント',
owner_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
liveblocks_room_id TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
is_public BOOLEAN DEFAULT FALSE
);
-- 共有権限テーブル
CREATE TABLE document_collaborators (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
permission TEXT CHECK (permission IN ('view', 'edit', 'admin')),
invited_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(document_id, user_id)
);
-- RLSポリシー(行レベルセキュリティ)
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "オーナーは全操作可能" ON documents
FOR ALL USING (auth.uid() = owner_id);
CREATE POLICY "コラボレーターは閲覧可能" ON documents
FOR SELECT USING (
EXISTS (
SELECT 1 FROM document_collaborators
WHERE document_id = id AND user_id = auth.uid()
)
OR is_public = TRUE
);
Step 8: オフライン対応と再接続ハンドリング
Yjsの強みはオフラインファースト設計です。ネットワーク切断中も編集を継続し、再接続時に自動マージします:
// src/hooks/useOfflineSync.ts
import { useEffect, useState } from "react";
import NetInfo from "@react-native-community/netinfo";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Y from "yjs";
const STORAGE_KEY_PREFIX = "yjs_doc_";
export function useOfflineSync(ydoc: Y.Doc, documentId: string) {
const [isOnline, setIsOnline] = useState(true);
// ネットワーク状態の監視
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsOnline(state.isConnected ?? false);
});
return unsubscribe;
}, []);
// ドキュメントをローカルに保存(デバウンス 2秒)
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const saveLocally = () => {
clearTimeout(timer);
timer = setTimeout(async () => {
try {
// Yjsドキュメントをバイナリにシリアライズ
const state = Y.encodeStateAsUpdate(ydoc);
const base64 = Buffer.from(state).toString("base64");
await AsyncStorage.setItem(
`${STORAGE_KEY_PREFIX}${documentId}`,
base64
);
} catch (error) {
console.warn("ローカル保存に失敗:", error);
}
}, 2000); // 2秒のデバウンス
};
ydoc.on("update", saveLocally);
return () => {
ydoc.off("update", saveLocally);
clearTimeout(timer);
};
}, [ydoc, documentId]);
// 起動時にローカルデータを読み込む
useEffect(() => {
const loadLocal = async () => {
try {
const base64 = await AsyncStorage.getItem(
`${STORAGE_KEY_PREFIX}${documentId}`
);
if (base64) {
const state = Buffer.from(base64, "base64");
// ローカル状態をYjsに適用(マージ)
Y.applyUpdate(ydoc, state);
}
} catch (error) {
console.warn("ローカルデータ読み込みに失敗:", error);
}
};
loadLocal();
}, [ydoc, documentId]);
return { isOnline };
}
パフォーマンス最適化:大規模ドキュメントへの対応
大きなドキュメント(数万文字以上)を扱う場合は以下の最適化を検討してください:
1. Yjs状態のスナップショット管理
// 定期的にスナップショットを取得し、古い更新履歴を切り詰める
const snapshot = Y.snapshot(ydoc);
const encodedSnapshot = Y.encodeSnapshot(snapshot);
// → サーバーに保存し、新規クライアントはスナップショットから開始
2. TextInputの仮想化(長文対応)
1万文字を超えるテキストはネイティブのTextInputがパフォーマンス低下することがあります。react-native-document-picker や @10play/tentap-editor(Tiptap for React Native)との組み合わせも検討しましょう。
3. Liveblocksのバッチ更新
// 高頻度な変更はバッチ処理で送信
ydoc.transact(() => {
yText.insert(0, "大量の");
yText.insert(6, "テキスト変更");
// → 1回のWebSocketメッセージにまとめられる
});
よくあるエラーと対処法
Q1. 「Connection refused」エラーが出る
LivblocksのパブリックAPIキーが正しいか確認してください。pk_live_ で始まるキーを使用しているか、また.env.local の変数名に EXPO_PUBLIC_ プレフィックスがあるかを確認します。
# 確認コマンド
echo $EXPO_PUBLIC_LIVEBLOCKS_PUBLIC_KEY
# → pk_live_... と表示されれば正常
Q2. 複数ユーザーの編集が衝突する
CRDTは数学的に競合しないはずですが、ローカル状態とリモート状態の適用タイミングが問題のケースがあります。isLocalChange フラグを正確に管理し、Yjsオブザーバーコールバック内でuseStateを呼んでいないか確認してください(Reactの非同期更新とYjsのシンクロナスな変更が混在するとバグの原因になります)。
Q3. オフラインで編集したデータが再接続後に失われる
LiveblocksYjsProvider はデフォルトでオフライン変更をメモリに保持しますが、アプリを終了すると失われます。useOfflineSync フックを使ってAsyncStorageに永続化してください。
個人開発者の視点から(実体験メモ)
まとめ:「協調」がアプリの価値を高める
リアルタイム協調編集機能は、かつては大手テック企業だけが実装できる複雑な機能でしました。しかしLiveblocksとYjsの登場により、個人開発者でも実用的な協調機能をRork Maxアプリに組み込めるようになりました。
本記事で学んだ内容を振り返ります:
- CRDT(Yjs) で競合なしのリアルタイム同期を実現する仕組み
- Liveblocksクライアント のセットアップとプレゼンス管理
- 差分アルゴリズム を使ったテキスト入力とYjsドキュメントの橋渡し
- Supabase によるドキュメントの永続化と共有権限管理
- オフライン対応 でネットワーク切断時も編集を継続できる設計
次のステップとして、このベースにリッチテキスト対応(@10play/tentap-editor)や図形描画の協調編集を加えると、より完成度の高いプロダクティビティアプリに成長させる形にできます。
協調機能をアプリに実装した方は、ぜひRork Labのブログでフィードバックを聞かせていただければ幸いです。