RxDB: base de datos reactiva en el navegador

RxDB: base de datos reactiva en el navegador

RxDB es una base de datos que vive en el navegador y que cambia de interfaz según cambien los datos. Por debajo guarda en IndexedDB, OPFS o SQLite WASM, y por arriba expone la API de RxJS. La consecuencia práctica es que una consulta no devuelve un arreglo fijo: devuelve un observable.

Esa diferencia es todo el cambio de fondo. En lugar de consultar, copiar el resultado a un estado de React y volver a consultar cada vez que algo se agrega, la consulta se suscribe y se actualiza sola.

Crear la base y la colección

import { createRxDatabase, addRxPlugin } from 'rxdb';
import { getRxStorageDexie } from 'rxdb/plugins/storage-dexie';

addRxPlugin(getRxStorageDexie);

const db = await createRxDatabase({
  name: 'appdb',
  storage: getRxStorageDexie(),
});

await db.addCollections({
  tareas: {
    schema: {
      version: 0,
      primaryKey: 'id',
      type: 'object',
      properties: {
        id: { type: 'string', maxLength: 100 },
        titulo: { type: 'string' },
        hecho: { type: 'boolean' },
        creado: { type: 'number' },
      },
      required: ['id', 'titulo', 'hecho'],
      indexes: ['hecho', 'creado'],
    },
  },
});

Dos campos del schema mandan más de lo que parecen. primaryKey define qué hace único a un documento. indexes es la lista de campos por los que RxDB puede filtrar, ordenar y después sincronizar: un campo que no está en indexes no se puede consultar de forma eficiente, y en replicación simplemente no viaja.

Consultas reactivas

const pendientes = db.tareas
  .find({ selector: { hecho: false } })
  .sort({ creado: 'asc' });

const sub = pendientes.$.subscribe((docs) => {
  console.log('pendientes:', docs.length);
});

await pendientes.insert({
  id: 'a1',
  titulo: 'Revisar RxDB',
  hecho: false,
  creado: Date.now(),
});

sub.unsubscribe();

find() es perezoso: arma la consulta y todavía no toca el almacenamiento. El carácter $ al final es el que marca la diferencia: pendientes.$ es el observable con los resultados y pendientes sigue siendo la consulta reutilizable. El insert de las tres líneas siguientes dispara el suscriptor con el arreglo nuevo. No hay recarga, no hay useEffect que dependa de un contador.

Escrituras

await db.tareas.bulkUpsert([
  { id: 'a1', titulo: 'Revisar RxDB', hecho: true, creado: 1 },
  { id: 'a2', titulo: 'Escribir el post', hecho: false, creado: 2 },
]);

await db.tareas.remove('a2');

db.tareas.findOne('a1').$.subscribe((doc) => {
  console.log(doc?.hecho);
});
NecesidadMétodo
Insertar o actualizar variosbulkUpsert
Insertar un documentoinsert
Borrar por clave primariaremove
Consultar un documentofindOne
Vaciar la colecciónremove sin selector

bulkUpsert es el más usado porque una escritura real casi siempre son varias filas a la vez, y agruparlas evita un evento de cambio por fila.

Replicación

import { replicateRxCollection } from 'rxdb/plugins/replication';

const sync = replicateRxCollection({
  collection: db.tareas,
  replicationIdentifier: 'app-tareas',
  live: true,
  pull: {
    handler: async (lastCheckpoint) => {
      const res = await fetch(`/api/tareas?since=${lastCheckpoint.id ?? 0}`);
      return res.json();
    },
  },
  push: {
    handler: async (rows) => {
      const res = await fetch('/api/tareas', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(rows),
      });
      return res.ok ? [] : [new Error('No se pudo guardar')];
    },
  },
});

sync.error$.subscribe(console.error);

pull y push son dos funciones propias, así que el transporte puede ser HTTP, GraphQL, WebSocket o un sincronizador de CouchDB sin cambiar la aplicación. Lo importante es el contrato: pull devuelve { documents, checkpoint } y el checkpoint es lo que permite después pedir solo lo que falta. push devuelve los conflictos que el servidor no aceptó. Con live: true la sincronización sigue corriendo, y como la colección es observable, la interfaz se actualiza sin polling.

RxJS encima

import { combineLatest, map } from 'rxjs';

const pendientes$ = db.tareas.find({ selector: { hecho: false } }).$;
const proyectos$ = db.proyectos.find().$;

combineLatest([pendientes$, proyectos$]).pipe(
  map(([tareas, proyectos]) => ({ total: tareas.length, proyectos: proyectos.length })),
).subscribe(console.log);

Los operadores de RxJS componen sin ningún estado intermedio. combineLatest emite cada vez que cualquiera de las dos fuentes cambia, que es justo lo que hace falta cuando el panel depende de dos colecciones.

Cuándo tiene sentido

SituaciónRxDB
La app debe funcionar sin conexiónSí
Los datos cambian mientras el usuario los miraSí
Se necesita consultar sin recargar la vistaSí
El servidor es la única fuente de verdadBasta con una API
Solo se guardan preferencias de la interfazlocalStorage es más simple

La decisión no es técnica, es de arquitectura. RxDB tiene sentido cuando el navegador es un nodo real del sistema y no solo una terminal. Cuando el servidor manda siempre y el cliente solo muestra, una consulta HTTP normal es menos código y menos conceptos.

© Arturo Juárez Monroy