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);
});
| Necesidad | Método |
|---|---|
| Insertar o actualizar varios | bulkUpsert |
| Insertar un documento | insert |
| Borrar por clave primaria | remove |
| Consultar un documento | findOne |
| Vaciar la colección | remove 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ón | RxDB |
|---|---|
| La app debe funcionar sin conexión | Sí |
| Los datos cambian mientras el usuario los mira | Sí |
| Se necesita consultar sin recargar la vista | Sí |
| El servidor es la única fuente de verdad | Basta con una API |
| Solo se guardan preferencias de la interfaz | localStorage 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