Классическая веб-архитектура (Cloud-First) построена на предположении, что централизованный сервер является единственным источником правды (Single Source of Truth), а браузерный клиент — лишь временным терминалом отображения данных. Любое действие пользователя (ввод символа, перетаскивание задачи, смена чекбокса) требует сетевого раундтрипа к API перед подтверждением изменений в интерфейсе. В условиях нестабильной мобильной связи, авиаперелетов или сбоев инфраструктуры это приводит к блокировке ввода, экранам загрузки и фатальной потере несохраненного прогресса.
Парадигма Local-First Software кардинально меняет правила игры:
Фундаментом Local-First систем выступают CRDT (Conflict-free Replicated Data Types) и высокопроизводительные браузерные хранилища — OPFS и IndexedDB.
В Local-First парадигме центральный сервер перестает быть монопольным арбитром транзакций. Клиент сам является полноценной базой данных. Вся бизнес-логика выполняется локально за 0 мс, а синхронизация между пирами сводится к обмену компактными бинарными дельтами математических структур CRDT.
В традиционных архитектурах слияние правок двух пользователей требует блокировки строк в БД или использования механизмов Operational Transformation (OT, как в классическом Google Docs), критически зависящих от центрального сервера-координатора.
В CRDT слияние состояний опирается на теорию полурешеток (Join-Semilattices), где операция объединения состояний merge(A, B) удовлетворяет трем фундаментальным математическим аксиомам:
merge(A, B) = merge(B, A) (порядок доставки пакетов по сети не влияет на итоговый результат).merge(merge(A, B), C) = merge(A, merge(B, C)) (пакеты могут группироваться и доставляться любыми пачками).merge(A, A) = A (повторная доставка дублирующихся сообщений при сбоях сети абсолютно безопасна).┌────────────────────────────────────────────────────────────────────────┐
│ LOCAL-FIRST РЕПЛИКАЦИЯ ЧЕРЕЗ CRDT │
│ │
│ Пользователь A (Оффлайн в поезде) Пользователь B (В офисе) │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ React 19 UI │ │ React 19 UI │ │
│ │ (Мгновенный ввод) │ │ (Мгновенный ввод) │ │
│ └──────────┬───────────┘ └──────────┬───────────┘ │
│ │ 0 мс задержка │ 0 мс задержка │
│ ▼ ▼ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Локальный CRDT Doc │ │ Локальный CRDT Doc │ │
│ │ (Yjs / Y.Doc) │ │ (Yjs / Y.Doc) │ │
│ ├──────────────────────┤ ├──────────────────────┤ │
│ │ IndexedDB / OPFS │ │ IndexedDB / OPFS │ │
│ │ (Персистентность) │ │ (Персистентность) │ │
│ └──────────┬───────────┘ └──────────┬───────────┘ │
│ │ │ │
│ │ (При появлении сети: бинарная дельта) │ (Sync Protocol)│
│ └───────────────────► ┌─────────────┐ ◄────┘ │
│ │ Relay-сервер│ │
│ │ (WebSocket) │ │
│ └─────────────┘ │
│ Автоматическое │
│ бесконфликтное слияние! │
└────────────────────────────────────────────────────────────────────────┘Фреймворк Yjs реализует алгоритм YATA — высокооптимизированную структуру CRDT для текста, массивов и JSON-деревьев, где каждый элемент оборачивается в иммутабельный блок с уникальным идентификатором ID(Client_ID, Clock):
Пользователь модифицирует Y.Map или Y.Text. Yjs создает блок с логическими часами за 0 мс.
Событие doc.on('update') перехватывается провайдером y-indexeddb, сбрасывающим дельту на локальный диск.
При подключении к сети клиент вычисляет вектор известных часов и запрашивает недостающие дельты.
Сервер ретрансляции пересылает только те бинарные блоки операций, которых еще нет у пира.
Хук useSyncExternalStore реагирует на изменение Y.Doc и инициирует безопасный рендер в React 19.
Какое математическое свойство операции слияния merge(A, B) гарантирует, что клиенты сойдутся к абсолютно идентичному состоянию даже в том случае, если сетевые пакеты пришли в разном порядке?
Подключение Yjs к React 19 требует использования нативного хука useSyncExternalStore для предотвращения разрыва согласованности интерфейса (Tearing) в Concurrent Mode:
'use client';
import * as React from 'react';
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { WebsocketProvider } from 'y-websocket';
export interface UserAwarenessState {
user: {
id: string;
name: string;
color: string;
};
cursor?: { x: number; y: number } | null;
}
export interface UseLocalFirstDocOptions {
docId: string;
websocketUrl?: string;
currentUser: UserAwarenessState['user'];
}
export interface UseLocalFirstDocReturn<T extends Record<string, unknown>> {
doc: Y.Doc;
data: T;
updateData: (mutator: (map: Y.Map<unknown>) => void) => void;
isSyncedLocal: boolean;
isSyncedRemote: boolean;
collaborators: Array<{ clientId: number; state: UserAwarenessState }>;
updateCursor: (cursor: { x: number; y: number } | null) => void;
}
export function useLocalFirstDoc<T extends Record<string, unknown>>(
options: UseLocalFirstDocOptions
): UseLocalFirstDocReturn<T> {
const { docId, websocketUrl = 'wss://sync.agenticlab.dev/yjs', currentUser } = options;
// Создаем единственный долгоживущий экземпляр Y.Doc на время жизни docId
const [doc] = React.useState(() => new Y.Doc());
const ymap = React.useMemo(() => doc.getMap('data'), [doc]);
const [isSyncedLocal, setIsSyncedLocal] = React.useState(false);
const [isSyncedRemote, setIsSyncedRemote] = React.useState(false);
const [collaborators, setCollaborators] = React.useState<
Array<{ clientId: number; state: UserAwarenessState }>
>([]);
const wsProviderRef = React.useRef<WebsocketProvider | null>(null);
// 1. Инициализация локального IndexedDB и сетевого WebSocket провайдеров
React.useEffect(() => {
const idbProvider = new IndexeddbPersistence(docId, doc);
idbProvider.on('synced', () => {
setIsSyncedLocal(true);
});
const wsProvider = new WebsocketProvider(websocketUrl, docId, doc, {
connect: true,
});
wsProviderRef.current = wsProvider;
wsProvider.on('status', (event: { status: 'connected' | 'disconnected' | 'connecting' }) => {
setIsSyncedRemote(event.status === 'connected');
});
// 2. Настройка протокола присутствия (Awareness Protocol)
wsProvider.awareness.setLocalStateField('user', currentUser);
const handleAwarenessChange = () => {
const states = wsProvider.awareness.getStates();
const activeCollaborators: Array<{ clientId: number; state: UserAwarenessState }> = [];
states.forEach((state, clientId) => {
if (clientId !== doc.clientID && (state as UserAwarenessState).user) {
activeCollaborators.push({
clientId,
state: state as UserAwarenessState,
});
}
});
setCollaborators(activeCollaborators);
};
wsProvider.awareness.on('change', handleAwarenessChange);
return () => {
wsProvider.awareness.off('change', handleAwarenessChange);
wsProvider.destroy();
idbProvider.destroy();
doc.destroy();
};
}, [docId, doc, websocketUrl, currentUser]);
// 3. Подписка React 19 через useSyncExternalStore для реактивного рендеринга
const subscribe = React.useCallback(
(onStoreChange: () => void) => {
ymap.observe(onStoreChange);
return () => {
ymap.unobserve(onStoreChange);
};
},
[ymap]
);
const getSnapshot = React.useCallback((): string => {
return JSON.stringify(ymap.toJSON());
}, [ymap]);
const rawJson = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const data = React.useMemo(() => JSON.parse(rawJson) as T, [rawJson]);
// Атомарный мутатор локального документа в рамках единой транзакции
const updateData = React.useCallback(
(mutator: (map: Y.Map<unknown>) => void) => {
doc.transact(() => {
mutator(ymap);
});
},
[doc, ymap]
);
const updateCursor = React.useCallback((cursor: { x: number; y: number } | null) => {
wsProviderRef.current?.awareness.setLocalStateField('cursor', cursor);
}, []);
return {
doc,
data,
updateData,
isSyncedLocal,
isSyncedRemote,
collaborators,
updateCursor,
};
}Пользователь модифицирует Y.Map или Y.Text. Yjs создает локальный блок с логическими часами Client_ID.
Событие doc.on('update') перехватывается провайдером y-indexeddb, сохраняющим дельту в хранилище браузера.
Клиент передает State Vector. Сервер возвращает только недостающие операции, упакованные в lib0 бинарный буфер.
Yjs сливает полученные байты, генерирует событие, и useSyncExternalStore уведомляет React о необходимости рендера.
Почему множественные изменения в Yjs обязательно должны оборачиваться в функцию doc.transact(() => ...), а не выполняться отдельными вызовами ymap.set()?
Ниже представлен компонент совместной Kanban-доски задач с поддержкой оффлайн-режима, живых аватаров пользователей онлайн и мгновенным откликом (0 мс):
'use client';
import * as React from 'react';
import { useLocalFirstDoc } from '@/shared/hooks/useLocalFirstDoc';
interface Task {
id: string;
title: string;
status: 'todo' | 'in_progress' | 'done';
}
interface BoardData {
tasks?: Record<string, Task>;
}
export function KanbanBoard({ boardId }: { boardId: string }) {
const currentUser = React.useMemo(
() => ({
id: `user-${Math.random().toString(36).substring(2, 7)}`,
name: 'Frontend Architect',
color: '#8B5CF6',
}),
[]
);
const { data, updateData, isSyncedLocal, isSyncedRemote, collaborators } =
useLocalFirstDoc<BoardData>({
docId: `board-${boardId}`,
currentUser,
});
const tasksList = React.useMemo(() => {
return Object.values(data.tasks ?? {});
}, [data.tasks]);
const handleAddTask = () => {
const newTaskId = `task-${Date.now()}`;
const newTask: Task = {
id: newTaskId,
title: `Задача #${tasksList.length + 1}`,
status: 'todo',
};
// Транзакция выполняется синхронно с 0 мс задержкой в UI!
updateData((map) => {
const currentTasks = (map.get('tasks') as Record<string, Task>) || {};
map.set('tasks', { ...currentTasks, [newTaskId]: newTask });
});
};
const handleMoveTask = (taskId: string, newStatus: Task['status']) => {
updateData((map) => {
const currentTasks = (map.get('tasks') as Record<string, Task>) || {};
const target = currentTasks[taskId];
if (target) {
map.set('tasks', {
...currentTasks,
[taskId]: { ...target, status: newStatus },
});
}
});
};
return (
<div className="p-6 rounded-2xl bg-bg-surface border border-border space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4 pb-4 border-b border-border">
<div>
<h2 className="text-xl font-bold text-text-primary">Local-First Workspace</h2>
<div className="flex items-center gap-3 text-xs mt-1">
<span className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${isSyncedLocal ? 'bg-emerald-400' : 'bg-yellow-400'}`} />
{isSyncedLocal ? 'Диск сохранен' : 'Сохранение...'}
</span>
<span className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${isSyncedRemote ? 'bg-emerald-400' : 'bg-red-400'}`} />
{isSyncedRemote ? 'Облако онлайн' : 'Оффлайн режим'}
</span>
</div>
</div>
{/* Аватары пользователей в реальном времени */}
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">Сейчас в документе:</span>
<div className="flex -space-x-2">
<div
className="w-7 h-7 rounded-full border-2 border-bg-surface flex items-center justify-center text-[10px] font-bold text-white"
style={{ backgroundColor: currentUser.color }}
title="Вы"
>
Я
</div>
{collaborators.map((c) => (
<div
key={c.clientId}
className="w-7 h-7 rounded-full border-2 border-bg-surface flex items-center justify-center text-[10px] font-bold text-white"
style={{ backgroundColor: c.state.user.color }}
title={c.state.user.name}
>
{c.state.user.name[0]}
</div>
))}
</div>
<button
type="button"
onClick={handleAddTask}
className="ml-4 px-3 py-1.5 bg-accent-violet text-white text-xs font-semibold rounded-lg hover:bg-accent-violet/90 transition-colors"
>
+ Добавить карточку
</button>
</div>
</div>
{/* Колонки задач */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(['todo', 'in_progress', 'done'] as const).map((statusKey) => (
<div key={statusKey} className="p-4 rounded-xl bg-bg-elevated/40 border border-border/60">
<h4 className="text-xs font-semibold uppercase tracking-wider text-text-secondary mb-3">
{statusKey.replace('_', ' ')}
</h4>
<div className="space-y-2">
{tasksList
.filter((t) => t.status === statusKey)
.map((task) => (
<div
key={task.id}
className="p-3 rounded-lg bg-bg-surface border border-border shadow-sm flex items-center justify-between"
>
<span className="text-sm font-medium text-text-primary">{task.title}</span>
{statusKey !== 'done' && (
<button
type="button"
onClick={() =>
handleMoveTask(
task.id,
statusKey === 'todo' ? 'in_progress' : 'done'
)
}
className="text-xs text-accent-violet hover:underline ml-2"
>
Далее →
</button>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}Изменение свойств объектов Yjs в цикле без использования метода doc.transact() приводит к генерации отдельного события обновления и отдельного сетевого пакета на каждый атрибут. Это вызывает каскадный шквал ререндеров React и перегружает сокет. Все связанные мутации обязаны оборачиваться в единую транзакцию.
// ❌ АНТИПАТТЕРН: Десятки сетевых пакетов и ререндеров
items.forEach((item) => {
ymap.set(item.id, item); // Каждый вызов триггерит отдельный sync-пакет!
});
// ✅ ПРАВИЛЬНО: Атомарная транзакция в едином блоке
doc.transact(() => {
items.forEach((item) => {
ymap.set(item.id, item);
});
}); // Генерируется ровно одна дельта!Попытка слушать события ymap.observe(() => setState(...)) через useState внутри useEffect приводит к эффекту мерцания и рассинхронизации (Tearing) в React 19 Concurrent Mode, когда разные компоненты на экране временно отображают разные версии документа. Интеграция с внешними CRDT-хранилищами обязана выполняться через useSyncExternalStore.
CRDT сохраняет полную историю операций (Tombstones — удаленные символы и блоки не удаляются физически, чтобы обеспечить схождение реплик). Помещение картинок в base64 или тяжелых PDF-документов в Y.Doc приведет к раздуванию документа до гигабайтных размеров. Бинарные ассеты должны сохраняться в OPFS или S3, а в CRDT записывается только их хэш (SHA-256) и URL.
Хотя API IndexedDB асинхронен, сериализация и десериализация огромных графов (более 50 МБ) блокирует основной поток JavaScript. Для высоконагруженных локальных баз (PGlite, SQLite) следует использовать Web Workers в связке с OPFS FileSystemSyncAccessHandle.
useSyncExternalStore для исключения эффекта Tearing в Concurrent Mode.doc.transact(), а персистентность диска защищается вызовом navigator.storage.persist().