La arquitectura web tradicional (Cloud-First) se fundamenta en la premisa de que un servidor remoto centralizado constituye la única fuente de verdad (Single Source of Truth), mientras que el navegador actúa meramente como un terminal de renderizado efímero. Cada acción del usuario (escribir un carácter, desplazar una tarjeta kanban o marcar una casilla de verificación) exige un viaje de ida y vuelta a la API antes de confirmar el cambio en pantalla. Ante redes móviles intermitentes, vuelos en avión o fallos de infraestructura en la nube, este paradigma genera bloqueos de entrada, pantallas de carga y pérdidas críticas de trabajo no guardado.
El paradigma Local-First Software transforma radicalmente este enfoque:
Los pilares de las arquitecturas Local-First residen en los CRDT (Conflict-free Replicated Data Types) y en subsistemas avanzados de almacenamiento del navegador como OPFS e IndexedDB.
En la arquitectura Local-First, el servidor centralizado deja de ser el árbitro monopolístico de las transacciones. El cliente es una base de datos autosuficiente. La lógica de negocio se ejecuta en local con 0 ms de latencia y la sincronización entre pares consiste en intercambiar deltas binarias compactas producidas por estructuras matemáticas CRDT.
En sistemas colaborativos tradicionales, fusionar cambios de varios usuarios requería bloqueos de filas en base de datos o complejos algoritmos de Transformación Operacional (OT, empleados en Google Docs clásico), con una dependencia crítica de un servidor centralizado.
En los CRDT, la reconciliación de estados se sustenta en la teoría de semirretículos de unión (Join-Semilattices), donde la operación de combinación merge(A, B) satisface tres axiomas matemáticos elementales:
merge(A, B) = merge(B, A) (el orden de llegada de los paquetes de red no altera el resultado convergente final).merge(merge(A, B), C) = merge(A, merge(B, C)) (los paquetes pueden agruparse y procesarse en cualquier lote o fragmentación).merge(A, A) = A (recibir paquetes duplicados ante reintentos de red no genera efectos colaterales indeseados).┌────────────────────────────────────────────────────────────────────────┐
│ REPLICACIÓN LOCAL-FIRST MEDIANTE CRDT │
│ │
│ Usuario A (Offline en el tren) Usuario B (En la oficina) │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ React 19 UI │ │ React 19 UI │ │
│ │ (Entrada inmediata)│ │ (Entrada inmediata)│ │
│ └──────────┬───────────┘ └──────────┬───────────┘ │
│ │ 0 ms latencia │ 0 ms latencia │
│ ▼ ▼ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Documento CRDT Local│ │ Documento CRDT Local│ │
│ │ (Yjs / Y.Doc) │ │ (Yjs / Y.Doc) │ │
│ ├──────────────────────┤ ├──────────────────────┤ │
│ │ IndexedDB / OPFS │ │ IndexedDB / OPFS │ │
│ │ (Persistencia local) │ │ (Persistencia local) │ │
│ └──────────┬───────────┘ └──────────┬───────────┘ │
│ │ │ │
│ │ (Al volver la red: delta binaria) │ (Sync Protocol)│
│ └───────────────────► ┌─────────────┐ ◄────┘ │
│ │ Servidor │ │
│ │ Relay (WS) │ │
│ └─────────────┘ │
│ ¡Convergencia │
│ automática sin │
│ conflictos! │
└────────────────────────────────────────────────────────────────────────┘El framework Yjs implementa el algoritmo YATA —una estructura CRDT altamente optimizada para texto enriquecido, arrays y árboles JSON, donde cada elemento se empaqueta en un bloque inmutable provisto de un identificador ID(Client_ID, Clock):
El usuario modifica un Y.Map o Y.Text. Yjs genera un bloque con reloj lógico en 0 ms.
El evento doc.on('update') es capturado por y-indexeddb, almacenando las deltas en el navegador.
Al establecer conexión, el cliente computa los relojes conocidos y solicita únicamente las operaciones ausentes.
El servidor de retransmisión transfiere solo los bloques de operaciones que el nodo no posee.
El hook useSyncExternalStore reacciona ante mutaciones en Y.Doc y desencadena un renderizado seguro sin desgarro visual.
¿Qué propiedad matemática de la función merge(A, B) garantiza que todas las réplicas converjan al mismo estado independientemente del orden de llegada de los paquetes de red?
Integrar Yjs en React 19 requiere el uso del hook nativo useSyncExternalStore para erradicar el desgarro de interfaz (Tearing) en modo concurrente:
'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;
// Instancia única y persistente de Y.Doc vinculada al 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. Inicialización de proveedores de almacenamiento IndexedDB y sync 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. Configuración de presencia en directo (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. Suscripción en React 19 vía useSyncExternalStore para evitar tearing
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]);
// Modificador atómico agrupado en una única transacción de documento
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,
};
}El usuario modifica Y.Map. Yjs añade un bloque local etiquetado con el reloj lógico del cliente.
El evento doc.on('update') activa y-indexeddb para guardar las deltas binarias en almacenamiento.
El cliente envía el State Vector. El servidor relay devuelve únicamente las deltas binarias que faltan.
Yjs procesa los bytes entrantes y useSyncExternalStore actualiza la vista sin desgarros visuales.
¿Por qué las modificaciones múltiples en Yjs deben encapsularse dentro de doc.transact(() => ...) en lugar de emitir llamadas individuales a ymap.set()?
A continuación se muestra un componente de tablero Kanban colaborativo con persistencia local, avatares de presencia en tiempo real y respuesta sin latencia (0 ms):
'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: `Tarea #${tasksList.length + 1}`,
status: 'todo',
};
// ¡La transacción se ejecuta de forma síncrona con 0 ms de demora en pantalla!
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 ? 'Disco guardado' : 'Guardando...'}
</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 ? 'Nube online' : 'Modo offline'}
</span>
</div>
</div>
{/* Presencia de colaboradores en tiempo real */}
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">En el documento:</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="Tú"
>
Yo
</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"
>
+ Añadir tarjeta
</button>
</div>
</div>
{/* Columnas de tareas */}
<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"
>
Siguiente →
</button>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}Modificar propiedades de Yjs dentro de bucles sin envolverlas en doc.transact() genera un evento de cambio individual y un paquete de red independiente por cada propiedad. Esto causa una cascada incontrolada de re-renderizados en React y satura la conexión. Agrupe siempre las mutaciones relacionadas en una única transacción.
// ❌ ANTIPATRÓN: Decenas de paquetes de red y renders
items.forEach((item) => {
ymap.set(item.id, item); // ¡Cada llamada emite un paquete individual!
});
// ✅ RECOMENDADO: Transacción atómica en bloque
doc.transact(() => {
items.forEach((item) => {
ymap.set(item.id, item);
});
}); // ¡Genera una sola delta combinada!Intentar escuchar ymap.observe(() => setState(...)) mediante useState dentro de useEffect provoca desincronización visual (Tearing) en el modo concurrente de React 19, donde diferentes componentes muestran versiones inconsistentes del mismo documento. La conexión con almacenes CRDT externos debe realizarse siempre a través de useSyncExternalStore.
Los CRDT mantienen un grafo histórico de operaciones (Tombstones: los elementos eliminados se conservan como marcas para preservar la convergencia de réplicas). Insertar imágenes en Base64 o documentos PDF en Y.Doc provocará que el documento crezca hasta gigabytes. Almacene los binarios en OPFS o S3 e inserte en el CRDT únicamente su hash (SHA-256) y URL.
Aunque las APIs de IndexedDB son asíncronas, serializar y deserializar grafos extensos (>50 MB) bloquea el hilo principal de JavaScript. Para bases de datos locales intensivas (PGlite, SQLite), ejecute el motor en Web Workers junto con FileSystemSyncAccessHandle de OPFS.
useSyncExternalStore para evitar el desgarro de interfaz (Tearing) en modo concurrente.doc.transact() y asegure la persistencia en disco con navigator.storage.persist().