Skip to content

Patrones Avanzados de Formularios usando JSON Patch ​

Al implementar formularios a gran escala o edición colaborativa en tiempo real, el enfoque tradicional de "enviar el formulario completo" genera problemas de rendimiento y experiencia de usuario.

Este artículo explica patrones avanzados de formularios usando JSON Patch (RFC 6902). Al enviar solo las diferencias, se reduce el ancho de banda de red y se puede implementar eficientemente Undo/Redo y edición colaborativa.

Qué aprenderás en este artículo ​

  • Fundamentos de JSON Patch/Pointer (RFC 6902/6901)
  • Autoguardado en formularios a gran escala (basado en diferencias)
  • Implementación de Undo/Redo (parches inversos)
  • Sincronización en tiempo real para edición colaborativa
  • Fundamentos de Operational Transform (OT) / CRDT
  • Patrones de integración de WebSocket y RxJS
  • Resolución de conflictos y gestión de versiones

Conocimientos previos

Este artículo asume conocimiento de Capítulo 4: Operadores, Patrones de manejo de formularios, y Procesamiento de datos en tiempo real.

Cuándo necesitas este patrón

  • Formularios a gran escala (más de 100 campos) que requieren autoguardado
  • Funcionalidad Undo/Redo es esencial
  • Edición colaborativa en tiempo real (funcionalidad similar a Google Docs)
  • Soporte offline con encolamiento de diferencias necesario

Para formularios pequeños (~20 campos), los patrones normales de manejo de formularios son suficientes.

Fundamentos de JSON Patch/Pointer ​

Qué es JSON Patch ​

JSON Patch (RFC 6902) es un formato estándar para expresar cambios en documentos JSON. Puedes enviar solo el contenido modificado en lugar del formulario completo.

typescript
// Datos del formulario antes del cambio
const before = {
  profile: {
    name: "Taro Tanaka",
    email: "tanaka@example.com",
    age: 30
  }
};

// Datos del formulario después del cambio
const after = {
  profile: {
    name: "Taro Tanaka",
    email: "tanaka.updated@example.com", // modificado
    age: 31 // modificado
  }
};

// JSON Patch (diferencia)
const patch = [
  { op: "replace", path: "/profile/email", value: "tanaka.updated@example.com" },
  { op: "replace", path: "/profile/age", value: 31 }
];

Las 6 operaciones de JSON Patch

  • add - Agregar un valor
  • remove - Eliminar un valor
  • replace - Reemplazar un valor
  • move - Mover un valor
  • copy - Copiar un valor
  • test - Probar un valor (validación)

Qué es JSON Pointer ​

JSON Pointer (RFC 6901) es una notación de ruta para apuntar a valores específicos dentro de un documento JSON.

typescript
const formData = {
  user: {
    profile: {
      name: "Taro Tanaka"
    },
    settings: {
      notifications: true
    }
  }
};

// Ejemplos de JSON Pointer
"/user/profile/name"           // → "Taro Tanaka"
"/user/settings/notifications" // → true
"/user/profile"                // → { name: "Taro Tanaka" }

Detección de diferencias con RxJS ​

Combina pairwise() con la librería fast-json-patch para detectar automáticamente los cambios en el formulario.

typescript
import { BehaviorSubject, pairwise, map } from 'rxjs';
import { compare } from 'fast-json-patch';

interface FormData {
  profile: {
    name: string;
    email: string;
    age: number;
  };
}

const initialData: FormData = {
  profile: {
    name: "",
    email: "",
    age: 0
  }
};

const formData$ = new BehaviorSubject<FormData>(initialData);

// Detectar diferencias
const patches$ = formData$.pipe(
  pairwise(), // Obtener el par [valor anterior, valor actual]
  map(([previous, current]) => compare(previous, current))
);

patches$.subscribe(patches => {
  console.log('Cambios detectados:', patches);
  // Ejemplo: [{ op: "replace", path: "/profile/name", value: "Taro Tanaka" }]
});

// Simular actualización de formulario
formData$.next({
  profile: {
    name: "Taro Tanaka",
    email: "tanaka@example.com",
    age: 30
  }
});

Librería fast-json-patch

bash
npm install fast-json-patch
  • Cumplimiento completo de RFC 6902
  • Generación de diferencias (compare) y aplicación (applyPatch)
  • Generación de parches inversos (para Undo)
  • Soporte TypeScript

Autoguardado y Undo/Redo en formularios a gran escala ​

Implementamos funcionalidad de autoguardado y Undo/Redo para formularios a gran escala (por ejemplo: registro de miembros con 100 campos, pantalla de gestión de productos).

Enfoque de implementación ​

Responsabilidades del Frontend:

  • Generación y secuenciación de operaciones (cambios)
  • Reflexión optimista de UI (scan para aplicación inmediata)
  • Gestión de pilas Undo/Redo (historial mediante parches inversos)
  • Gestión de cola de envío (garantía de orden con concatMap)
  • Agrupación en lotes (bufferTime + compresión)

Responsabilidades del Backend:

  • Gestión de versiones (Vector Clock / timestamp)
  • Garantía de idempotencia (detección de duplicados mediante Request ID)
  • Persistencia y registro de auditoría

Patrón 1: Autoguardado básico ​

Detecta cambios en el formulario y envía lotes al servidor a intervalos regulares.

typescript
import {
  BehaviorSubject,
  Subject,
  pairwise,
  map,
  bufferTime,
  filter,
  concatMap,
  catchError,
  of
} from 'rxjs';
import { compare, Operation } from 'fast-json-patch';

interface LargeFormData {
  personalInfo: {
    firstName: string;
    lastName: string;
    email: string;
    phone: string;
    dateOfBirth: string;
  };
  address: {
    street: string;
    city: string;
    state: string;
    zipCode: string;
    country: string;
  };
  preferences: {
    newsletter: boolean;
    notifications: boolean;
    theme: 'light' | 'dark';
  };
  // ... Asumiendo más de 100 campos
}

const initialFormData: LargeFormData = {
  personalInfo: {
    firstName: "",
    lastName: "",
    email: "",
    phone: "",
    dateOfBirth: ""
  },
  address: {
    street: "",
    city: "",
    state: "",
    zipCode: "",
    country: "Japan"
  },
  preferences: {
    newsletter: false,
    notifications: true,
    theme: 'light'
  }
};

// Stream de datos del formulario
const formData$ = new BehaviorSubject<LargeFormData>(initialFormData);

// Stream de resultados de guardado
const saveResult$ = new Subject<{ success: boolean; message: string }>();

// Elemento de visualización de estado para demostración
const statusDiv = document.createElement('div');
statusDiv.style.padding = '10px';
statusDiv.style.margin = '10px';
statusDiv.style.border = '2px solid #ccc';
statusDiv.style.borderRadius = '4px';
statusDiv.style.fontFamily = 'monospace';
statusDiv.style.fontSize = '14px';
document.body.appendChild(statusDiv);

function updateStatus(message: string, color: string = '#333') {
  statusDiv.innerHTML = `<span style="color: ${color}">${message}</span>`;
}

// Pipeline de autoguardado
formData$.pipe(
  pairwise(),
  map(([previous, current]) => ({
    patches: compare(previous, current),
    timestamp: Date.now()
  })),
  filter(({ patches }) => patches.length > 0), // Omitir si no hay cambios
  bufferTime(2000), // Almacenar en buffer los cambios durante 2 segundos
  filter(buffer => buffer.length > 0), // Omitir buffers vacíos
  map(buffer => {
    // Consolidar todos los parches del buffer en un solo array
    const allPatches = buffer.flatMap(item => item.patches);
    updateStatus(`📦 Procesando ${allPatches.length} cambios en lote...`, '#FF9800');
    return allPatches;
  }),
  concatMap(patches => saveToServer(patches)), // Enviar con orden garantizado
  catchError((error: unknown) => {
    console.error('Error de autoguardado:', error);
    updateStatus(`❌ Fallo al guardar: ${(error instanceof Error ? error.message : String(error))}`, '#f44336');
    return of({ success: false, message: (error instanceof Error ? error.message : String(error)) });
  })
).subscribe(result => {
  if (result.success) {
    updateStatus(`✅ Autoguardado completado (${new Date().toLocaleTimeString()})`, '#4CAF50');
  }
  saveResult$.next(result);
});

// Guardar en servidor (implementación mock)
function saveToServer(patches: Operation[]): Promise<{ success: boolean; message: string }> {
  console.log('Enviando al servidor:', patches);

  // Ejemplo de implementación real:
  // return fetch('/api/forms/12345/patches', {
  //   method: 'PATCH',
  //   headers: { 'Content-Type': 'application/json-patch+json' },
  //   body: JSON.stringify(patches)
  // }).then(res => res.json());

  // Mock: devolver éxito después de 500ms
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        success: true,
        message: `Guardados ${patches.length} cambios`
      });
    }, 500);
  });
}

// Demo: Simular cambio de formulario
const demoButton = document.createElement('button');
demoButton.textContent = 'Cambiar formulario (Demo)';
demoButton.style.padding = '10px 20px';
demoButton.style.margin = '10px';
demoButton.style.fontSize = '16px';
demoButton.style.cursor = 'pointer';
document.body.appendChild(demoButton);

demoButton.addEventListener('click', () => {
  // Cambiar campos aleatoriamente
  const currentData = formData$.getValue();
  const updatedData = {
    ...currentData,
    personalInfo: {
      ...currentData.personalInfo,
      firstName: `Taro_${Math.floor(Math.random() * 100)}`,
      email: `taro${Math.floor(Math.random() * 100)}@example.com`
    },
    preferences: {
      ...currentData.preferences,
      newsletter: !currentData.preferences.newsletter
    }
  };
  formData$.next(updatedData);
  updateStatus('📝 Formulario modificado...', '#2196F3');
});

Puntos clave del autoguardado

  • bufferTime(2000) - Agrupar cambios de 2 segundos para enviar juntos (eficiencia de red)
  • concatMap - Garantizar el orden de los parches (mergeMap puede alterar el orden)
  • filter - Omitir cuando no hay cambios (reducir solicitudes innecesarias)
  • Idempotencia - Seguro enviar el mismo parche varias veces (agregar Request ID)

Patrón 2: Implementación de Undo/Redo ​

Implementa funcionalidad Undo/Redo usando parches inversos.

typescript
import { Subject, scan } from 'rxjs';
import { applyPatch, Operation, deepClone } from 'fast-json-patch';

interface HistoryState<T> {
  current: T;
  undoStack: Operation[][];
  redoStack: Operation[][];
}

interface HistoryAction {
  type: 'APPLY_PATCH' | 'UNDO' | 'REDO';
  patches?: Operation[];
}

// Stream de gestión de historial
const historyAction$ = new Subject<HistoryAction>();

const initialState: HistoryState<LargeFormData> = {
  current: initialFormData,
  undoStack: [],
  redoStack: []
};

// Reducer para gestionar el historial
const history$ = historyAction$.pipe(
  scan((state, action) => {
    switch (action.type) {
      case 'APPLY_PATCH':
        if (!action.patches || action.patches.length === 0) return state;

        // Aplicar parche
        const cloned = deepClone(state.current);
        const result = applyPatch(cloned, action.patches, true, false);

        return {
          current: result.newDocument,
          undoStack: [...state.undoStack, action.patches],
          redoStack: [] // Limpiar pila de Redo con nueva operación
        };

      case 'UNDO':
        if (state.undoStack.length === 0) return state;

        const patchesToUndo = state.undoStack[state.undoStack.length - 1];
        const beforeUndo = deepClone(state.current);

        // Generar y aplicar parche inverso
        const inversePatch = generateInversePatch(state.current, patchesToUndo);
        const undoResult = applyPatch(beforeUndo, inversePatch, true, false);

        return {
          current: undoResult.newDocument,
          undoStack: state.undoStack.slice(0, -1),
          redoStack: [...state.redoStack, patchesToUndo]
        };

      case 'REDO':
        if (state.redoStack.length === 0) return state;

        const patchesToRedo = state.redoStack[state.redoStack.length - 1];
        const beforeRedo = deepClone(state.current);
        const redoResult = applyPatch(beforeRedo, patchesToRedo, true, false);

        return {
          current: redoResult.newDocument,
          undoStack: [...state.undoStack, patchesToRedo],
          redoStack: state.redoStack.slice(0, -1)
        };

      default:
        return state;
    }
  }, initialState)
);

// Generación de parche inverso (implementación simplificada)
function generateInversePatch(document: any, patches: Operation[]): Operation[] {
  // El applyPatch de fast-json-patch devuelve el parche inverso cuando el 4to argumento es true
  const cloned = deepClone(document);
  const result = applyPatch(cloned, patches, true, true);
  return result[1] || []; // Obtener parche inverso
}

// Elementos de UI
const historyControlDiv = document.createElement('div');
historyControlDiv.style.padding = '10px';
historyControlDiv.style.margin = '10px';
document.body.appendChild(historyControlDiv);

const undoButton = document.createElement('button');
undoButton.textContent = '↶ Undo';
undoButton.style.padding = '10px 20px';
undoButton.style.marginRight = '10px';
undoButton.style.fontSize = '16px';
undoButton.style.cursor = 'pointer';
historyControlDiv.appendChild(undoButton);

const redoButton = document.createElement('button');
redoButton.textContent = '↷ Redo';
redoButton.style.padding = '10px 20px';
redoButton.style.fontSize = '16px';
redoButton.style.cursor = 'pointer';
historyControlDiv.appendChild(redoButton);

const historyInfo = document.createElement('div');
historyInfo.style.marginTop = '10px';
historyInfo.style.fontFamily = 'monospace';
historyInfo.style.fontSize = '14px';
historyControlDiv.appendChild(historyInfo);

// Mostrar estado del historial
history$.subscribe(state => {
  undoButton.disabled = state.undoStack.length === 0;
  redoButton.disabled = state.redoStack.length === 0;

  historyInfo.innerHTML = `
    📚 Undo disponible: ${state.undoStack.length} veces<br>
    📚 Redo disponible: ${state.redoStack.length} veces<br>
    📝 Valor actual: ${JSON.stringify(state.current.personalInfo.firstName)}
  `;

  // Sincronizar datos del formulario
  formData$.next(state.current);
});

// Eventos de botones
undoButton.addEventListener('click', () => {
  historyAction$.next({ type: 'UNDO' });
});

redoButton.addEventListener('click', () => {
  historyAction$.next({ type: 'REDO' });
});

// Demo: Botón de aplicación de parches
const applyPatchButton = document.createElement('button');
applyPatchButton.textContent = 'Aplicar cambio (Test Undo/Redo)';
applyPatchButton.style.padding = '10px 20px';
applyPatchButton.style.margin = '10px';
applyPatchButton.style.fontSize = '16px';
applyPatchButton.style.cursor = 'pointer';
document.body.appendChild(applyPatchButton);

applyPatchButton.addEventListener('click', () => {
  const patches: Operation[] = [
    { op: 'replace', path: '/personalInfo/firstName', value: `Taro_${Date.now()}` }
  ];
  historyAction$.next({ type: 'APPLY_PATCH', patches });
});

Puntos clave de Undo/Redo

  • Parche inverso - Establecer el 4to argumento de applyPatch en true permite obtener el parche inverso
  • Gestión de pilas - Pila de Undo (operaciones pasadas) y pila de Redo (operaciones deshechas)
  • Limpiar Redo con nueva operación - Resetear pila de Redo cuando hay un nuevo cambio
  • Patrón Reducer con scan - Implementar gestión de estado similar a useReducer de React

Patrón 3: Soporte offline (Cola de IndexedDB) ​

Durante el modo offline, encola los cambios en IndexedDB y sincroniza cuando se recupera la conexión.

typescript
import { fromEvent, merge, map, filter, concatMap, catchError, of } from 'rxjs';

// Estado online/offline
const online$ = merge(
  fromEvent(window, 'online').pipe(map(() => true)),
  fromEvent(window, 'offline').pipe(map(() => false))
);

// Operaciones de IndexedDB (implementación simplificada)
class PatchQueue {
  private dbName = 'form-patches';
  private storeName = 'patches';
  private db: IDBDatabase | null = null;

  async init(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        if (!db.objectStoreNames.contains(this.storeName)) {
          db.createObjectStore(this.storeName, {
            keyPath: 'id',
            autoIncrement: true
          });
        }
      };
    });
  }

  async enqueue(patches: Operation[]): Promise<void> {
    if (!this.db) throw new Error('DB no inicializada');

    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      const request = store.add({
        patches,
        timestamp: Date.now()
      });

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async dequeueAll(): Promise<Operation[][]> {
    if (!this.db) throw new Error('DB no inicializada');

    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      const request = store.getAll();

      request.onsuccess = () => {
        const items = request.result;
        // Limpiar después de obtener
        store.clear();
        resolve(items.map((item: any) => item.patches));
      };
      request.onerror = () => reject(request.error);
    });
  }
}

const patchQueue = new PatchQueue();
patchQueue.init().then(() => {
  console.log('IndexedDB inicializado');
});

// Autoguardado con soporte offline
formData$.pipe(
  pairwise(),
  map(([previous, current]) => compare(previous, current)),
  filter(patches => patches.length > 0),
  bufferTime(2000),
  filter(buffer => buffer.length > 0),
  map(buffer => buffer.flatMap(patches => patches)),
  concatMap(async (patches) => {
    const isOnline = navigator.onLine;

    if (isOnline) {
      // Online: Enviar al servidor
      try {
        return await saveToServer(patches);
      } catch (error) {
        // Fallo de envío: Agregar a cola
        await patchQueue.enqueue(patches);
        return { success: false, message: 'Agregado a cola offline' };
      }
    } else {
      // Offline: Agregar a cola
      await patchQueue.enqueue(patches);
      console.log('📴 Offline: Agregado a cola');
      return { success: false, message: 'Offline' };
    }
  })
).subscribe();

// Sincronizar al recuperar conexión
online$.pipe(
  filter(isOnline => isOnline),
  concatMap(async () => {
    console.log('🌐 Conexión recuperada: Sincronizando cola...');
    const queuedPatches = await patchQueue.dequeueAll();

    for (const patches of queuedPatches) {
      await saveToServer(patches);
    }

    return { synced: queuedPatches.length };
  })
).subscribe(result => {
  console.log(`✅ ${result.synced} parches sincronizados`);
});

Puntos clave del soporte offline

  • IndexedDB - Almacenamiento persistente del lado del navegador (mayor capacidad que LocalStorage)
  • Encolamiento - Acumular en cola de envío durante modo offline
  • Detección de recuperación online - Monitorear eventos con window.addEventListener('online')
  • Garantía de orden - Enviar parches en la cola secuencialmente con concatMap

Sincronización en tiempo real para edición colaborativa ​

Implementa "edición colaborativa en tiempo real" donde múltiples usuarios editan el mismo formulario simultáneamente (funcionalidad similar a Google Docs, Notion, Figma).

Fundamentos de Operational Transform (OT) y CRDT ​

En la edición colaborativa en tiempo real, la resolución de conflictos es el mayor desafío. Existen dos enfoques principales.

Operational Transform (OT) ​

Resuelve conflictos transformando las operaciones de cambio.

Usuario A: "hello" → "hello world" (agregar " world" al final)
Usuario B: "hello" → "Hi hello"   (agregar "Hi " al principio)

【Sin transformación】
Resultado: "Hi hello world" o "hello world" (último gana)

【Con OT】
- Transformar operación del Usuario A con operación del Usuario B
- Transformar operación del Usuario B con operación del Usuario A
→ Resultado: "Hi hello world" (mantener ambos cambios)

Ventajas:

  • Resultado intuitivo (mantener ambos cambios)
  • El servidor determina el estado final

Desventajas:

  • Implementación compleja
  • Requiere servidor

CRDT (Conflict-free Replicated Data Types) ​

Utiliza estructuras de datos que matemáticamente no generan conflictos.

Asignar ID único a cada carácter:

Usuario A: [h1, e2, l3, l4, o5] → [h1, e2, l3, l4, o5, space6, w7, o8, r9, l10, d11]
Usuario B: [h1, e2, l3, l4, o5] → [H12, i13, space14, h1, e2, l3, l4, o5]

Ordenar por ID al fusionar:
→ [H12, i13, space14, h1, e2, l3, l4, o5, space6, w7, o8, r9, l10, d11]
→ "Hi hello world"

Ventajas:

  • No requiere servidor (posible P2P)
  • Fácil soporte offline

Desventajas:

  • Alto uso de memoria
  • Procesamiento de eliminación complejo (método Tombstone)

Selección de librerías

  • Implementación OT: ShareDB - Operational Transform
  • Implementación CRDT: Yjs - CRDT de alto rendimiento (recomendado)
  • Implementación CRDT: Automerge - Especializado en JSON

Este artículo presenta ejemplos de implementación usando Yjs (CRDT).

Patrón 4: Edición colaborativa usando Yjs ​

Combina Yjs y RxJS para implementar edición colaborativa en tiempo real.

typescript
import { fromEvent, merge, Subject } from 'rxjs';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

// Documento Yjs (estado compartido)
const ydoc = new Y.Doc();

// Datos del formulario compartido (tipo Y.Map de Yjs)
const yFormData = ydoc.getMap('formData');

// Proveedor WebSocket (conexión al servidor)
// En producción, construir servidor WebSocket propio
const wsProvider = new WebsocketProvider(
  'wss://demos.yjs.dev', // Servidor público para demostración
  'rxjs-form-demo',      // Nombre de sala
  ydoc
);

// Subject de RxJS para cambios de formulario
const formChange$ = new Subject<{
  key: string;
  value: any;
  user: string;
}>();

// Convertir cambios de Yjs a stream de RxJS
yFormData.observe(event => {
  event.changes.keys.forEach((change, key) => {
    if (change.action === 'add' || change.action === 'update') {
      formChange$.next({
        key: key,
        value: yFormData.get(key),
        user: 'remote'
      });
    }
  });
});

// UI de demostración
const collaborativeFormDiv = document.createElement('div');
collaborativeFormDiv.style.padding = '20px';
collaborativeFormDiv.style.margin = '10px';
collaborativeFormDiv.style.border = '2px solid #2196F3';
collaborativeFormDiv.style.borderRadius = '8px';
collaborativeFormDiv.style.backgroundColor = '#f5f5f5';
document.body.appendChild(collaborativeFormDiv);

const title = document.createElement('h3');
title.textContent = '🤝 Demo de edición colaborativa en tiempo real';
title.style.margin = '0 0 15px 0';
collaborativeFormDiv.appendChild(title);

const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.placeholder = 'Ingresa nombre (sincronizado con otros usuarios)';
nameInput.style.padding = '10px';
nameInput.style.width = '100%';
nameInput.style.fontSize = '16px';
nameInput.style.border = '2px solid #ccc';
nameInput.style.borderRadius = '4px';
nameInput.style.boxSizing = 'border-box';
collaborativeFormDiv.appendChild(nameInput);

const syncStatus = document.createElement('div');
syncStatus.style.marginTop = '10px';
syncStatus.style.fontSize = '14px';
syncStatus.style.color = '#666';
collaborativeFormDiv.appendChild(syncStatus);

const activeUsers = document.createElement('div');
activeUsers.style.marginTop = '10px';
activeUsers.style.fontSize = '14px';
activeUsers.style.color = '#666';
collaborativeFormDiv.appendChild(activeUsers);

// Monitorear estado de conexión
wsProvider.on('status', (event: { status: string }) => {
  if (event.status === 'connected') {
    syncStatus.innerHTML = '🟢 <strong>Conectado</strong> - Sincronización en tiempo real con otros usuarios';
    syncStatus.style.color = '#4CAF50';
  } else {
    syncStatus.innerHTML = '🔴 <strong>Desconectado</strong>';
    syncStatus.style.color = '#f44336';
  }
});

// Mostrar número de usuarios conectados (Awareness API)
wsProvider.awareness.on('change', () => {
  const users = Array.from(wsProvider.awareness.getStates().keys());
  activeUsers.innerHTML = `👥 Usuarios activos: ${users.length} personas`;
});

// Reflejar cambios locales en Yjs
let isRemoteChange = false;

fromEvent(nameInput, 'input').subscribe(() => {
  if (!isRemoteChange) {
    yFormData.set('name', nameInput.value);
  }
});

// Reflejar cambios remotos en UI
formChange$.subscribe(change => {
  if (change.key === 'name') {
    isRemoteChange = true;
    nameInput.value = change.value || '';
    nameInput.style.borderColor = '#4CAF50';

    setTimeout(() => {
      nameInput.style.borderColor = '#ccc';
      isRemoteChange = false;
    }, 500);
  }
});

// Configurar valor inicial
const initialName = yFormData.get('name');
if (initialName) {
  nameInput.value = initialName;
}

Puntos clave de Yjs

  • Y.Doc - Documento compartido (CRDT)
  • Y.Map - Tipo Map compartido ({ key: value })
  • WebsocketProvider - Sincronización vía WebSocket
  • Awareness API - Información de presencia (usuarios conectados, posición del cursor)
  • observe - Monitorear cambios y convertir a stream de RxJS

Patrón 5: Gestión de presencia (Compartir posición del cursor) ​

Visualiza quién está editando dónde.

typescript
import { throttleTime } from 'rxjs';

interface UserPresence {
  userId: string;
  name: string;
  color: string;
  cursorPosition: number;
  timestamp: number;
}

// Generar color aleatorio
function generateRandomColor(): string {
  const colors = ['#f44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#00BCD4', '#009688'];
  return colors[Math.floor(Math.random() * colors.length)];
}

// ID de usuario propio (generado aleatoriamente)
const myUserId = `user_${Math.random().toString(36).substr(2, 9)}`;
const myColor = generateRandomColor();

// Configurar Awareness (información de presencia)
wsProvider.awareness.setLocalState({
  userId: myUserId,
  name: `Usuario${myUserId.slice(-4)}`,
  color: myColor
});

// Detectar cambio de posición del cursor (limitar frecuencia de envío con throttle)
fromEvent(nameInput, 'selectionchange').pipe(
  throttleTime(200)
).subscribe(() => {
  const cursorPosition = nameInput.selectionStart || 0;

  wsProvider.awareness.setLocalStateField('cursorPosition', cursorPosition);
});

// Mostrar posición del cursor de otros usuarios
const cursorOverlay = document.createElement('div');
cursorOverlay.style.position = 'relative';
cursorOverlay.style.marginTop = '10px';
cursorOverlay.style.fontSize = '12px';
collaborativeFormDiv.appendChild(cursorOverlay);

wsProvider.awareness.on('change', () => {
  const states = wsProvider.awareness.getStates();
  const cursors: string[] = [];

  states.forEach((state: any, clientId: number) => {
    if (state.userId !== myUserId) {
      cursors.push(
        `<span style="color: ${state.color}">● ${state.name}</span> (posición: ${state.cursorPosition || 0})`
      );
    }
  });

  cursorOverlay.innerHTML = cursors.length > 0
    ? `📍 Otros usuarios: ${cursors.join(', ')}`
    : '📍 No hay otros usuarios';
});

Puntos clave de gestión de presencia

  • awareness.setLocalState - Compartir información propia
  • awareness.getStates - Obtener información de todos los usuarios
  • throttleTime - Limitar frecuencia de envío de movimiento del cursor (200ms)
  • Codificación por colores - Asignar color a cada usuario para mejorar visibilidad

Patrón 6: Manejo de errores y reconexión ​

Implementa reconexión y manejo de errores cuando se desconecta WebSocket.

typescript
import { timer, takeUntil, Subject } from 'rxjs';

const disconnect$ = new Subject<void>();

// Detectar desconexión de WebSocket
wsProvider.on('connection-close', () => {
  console.warn('⚠️ WebSocket desconectado');
  syncStatus.innerHTML = '🟡 <strong>Reconectando...</strong>';
  syncStatus.style.color = '#FF9800';

  // Intentar reconexión después de 5 segundos
  timer(5000).pipe(
    takeUntil(disconnect$)
  ).subscribe(() => {
    console.log('🔄 Intentando reconexión');
    wsProvider.connect();
  });
});

// Manejo de errores de WebSocket
wsProvider.on('connection-error', (error: Error) => {
  console.error('❌ Error de WebSocket:', error);
  syncStatus.innerHTML = `❌ <strong>Error:</strong> ${error.message}`;
  syncStatus.style.color = '#f44336';
});

// Limpieza
window.addEventListener('beforeunload', () => {
  disconnect$.next();
  wsProvider.disconnect();
  ydoc.destroy();
});

Precauciones para entorno de producción

  • Servidor WebSocket propio - wss://demos.yjs.dev es para desarrollo. En producción, construir y-websocket-server
  • Autenticación - Implementar autenticación por token al conectar WebSocket
  • Escalado - Compartir estado entre servidores WebSocket usando Redis, etc.
  • Persistencia - Guardar documentos Yjs en base de datos (y-leveldb, y-indexeddb)

Resumen ​

Este artículo explicó patrones avanzados de formularios usando JSON Patch.

Puntos importantes ​

Criterios de selección de patrones de JSON Patch

Cuando el procesamiento normal de formularios es suficiente:

Cuando JSON Patch es necesario:

  • Número de campos: Más de 100
  • Autoguardado: Esencial (enviar solo diferencias)
  • Undo/Redo: Esencial
  • Edición colaborativa: Sincronización en tiempo real necesaria → Usar los patrones de este artículo

Resumen de patrones de implementación ​

PatrónCaso de usoTecnologías principales
Autoguardado básicoEnvío de diferencias en formularios a gran escalapairwise + bufferTime + concatMap
Undo/RedoGestión de historial de operacionesParches inversos + scan
Soporte offlineRespuesta ante desconexión de redIndexedDB + Encolamiento
Edición colaborativa (Yjs)Sincronización en tiempo realYjs (CRDT) + WebSocket
Gestión de presenciaCompartir posición del cursorAwareness API + throttleTime

Próximos pasos ​

Recursos de referencia ​

Especificaciones estándar ​

Librerías ​

  • fast-json-patch - Implementación de JSON Patch (conforme a RFC)
  • Yjs - Implementación CRDT (edición colaborativa)
  • ShareDB - Implementación de Operational Transform
  • Automerge - CRDT especializado en JSON

Recursos de aprendizaje ​

Publicado bajo licencia CC-BY-4.0.