Класична вебархітектура (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().