IndexedDB en JavaScript: bases de datos en el navegador con índices y transacciones

Cuando necesitas almacenar datos estructurados en el navegador que superen los pocos kilobytes de localStorage o que requieran índices, búsquedas eficientes y transacciones, IndexedDB es la solución. Es una base de datos NoSQL orientada a objetos que vive en el navegador, persiste entre sesiones y puede almacenar prácticamente cualquier objeto JavaScript serializable.

Abrir una base de datos y crear object stores

IndexedDB es asíncrona y basada en eventos. La apertura de la base de datos devuelve una petición (IDBOpenDBRequest) con callbacks para el éxito y la creación/actualización del esquema:

function abrirDB() {
  return new Promise((resolve, reject) => {
    const peticion = indexedDB.open('MiAppDB', 1);

    peticion.onerror = () => reject(peticion.error);
    peticion.onsuccess = () => resolve(peticion.result);

    // Solo se ejecuta si la versión aumenta o es la primera vez
    peticion.onupgradeneeded = (e) => {
      const db = e.target.result;

      // Object store con clave autoincrementada
      const articulos = db.createObjectStore('articulos', {
        keyPath: 'id',
        autoIncrement: true,
      });

      // Índices para búsquedas eficientes
      articulos.createIndex('por_categoria', 'categoria', { unique: false });
      articulos.createIndex('por_fecha', 'fecha', { unique: false });

      // Object store con clave manual (string)
      db.createObjectStore('configuracion', { keyPath: 'clave' });
    };
  });
}

CRUD básico con transacciones

Todas las operaciones en IndexedDB se realizan dentro de transacciones. Una transacción tiene un scope (los object stores que puede tocar) y un modo (readonly o readwrite):

async function guardarArticulo(db, articulo) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('articulos', 'readwrite');
    const store = tx.objectStore('articulos');
    const peticion = store.put(articulo); // put: inserta o actualiza

    peticion.onsuccess = () => resolve(peticion.result); // devuelve el id
    tx.onerror = () => reject(tx.error);
  });
}

async function leerArticulo(db, id) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('articulos', 'readonly');
    const peticion = tx.objectStore('articulos').get(id);
    peticion.onsuccess = () => resolve(peticion.result);
    peticion.onerror = () => reject(peticion.error);
  });
}

async function eliminarArticulo(db, id) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('articulos', 'readwrite');
    const peticion = tx.objectStore('articulos').delete(id);
    peticion.onsuccess = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

Consultas por índice con IDBIndex

Los índices permiten buscar y ordenar por campos distintos a la clave primaria:

async function articulosPorCategoria(db, categoria) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('articulos', 'readonly');
    const indice = tx.objectStore('articulos').index('por_categoria');
    const peticion = indice.getAll(categoria); // todos los que coincidan

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

// Rango de fechas con IDBKeyRange
async function articulosEnRango(db, inicio, fin) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('articulos', 'readonly');
    const indice = tx.objectStore('articulos').index('por_fecha');
    const rango = IDBKeyRange.bound(inicio, fin);
    const peticion = indice.getAll(rango);

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

Cursor para recorrer grandes conjuntos

Para procesar muchos registros sin cargarlos todos en memoria a la vez, usa un IDBCursor:

async function procesarTodos(db, procesarFn) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('articulos', 'readonly');
    const peticion = tx.objectStore('articulos').openCursor();

    peticion.onsuccess = (e) => {
      const cursor = e.target.result;
      if (!cursor) { resolve(); return; } // fin del recorrido

      procesarFn(cursor.value);
      cursor.continue(); // avanzar al siguiente
    };
    peticion.onerror = () => reject(peticion.error);
  });
}

La librería idb: IndexedDB con promesas

La librería idb (de Jake Archibald) envuelve IndexedDB con una API basada en promesas y async/await, eliminando toda la verbosidad de los eventos:

import { openDB } from 'idb';

const db = await openDB('MiAppDB', 1, {
  upgrade(db) {
    const store = db.createObjectStore('articulos', { keyPath: 'id', autoIncrement: true });
    store.createIndex('categoria', 'categoria');
  },
});

// CRUD limpio con async/await
await db.put('articulos', { titulo: 'Hola', categoria: 'js', fecha: new Date() });
const todos = await db.getAllFromIndex('articulos', 'categoria', 'js');
const uno = await db.get('articulos', 1);
await db.delete('articulos', 1);

// Transacción explícita
const tx = db.transaction('articulos', 'readwrite');
await tx.store.put({ titulo: 'Nuevo', categoria: 'css', fecha: new Date() });
await tx.done;

IndexedDB es la opción adecuada para datos que deben persistir entre sesiones, pueden crecer más allá de unos pocos KB, o necesitan índices y búsquedas. Para datos temporales o pequeñas preferencias, localStorage o sessionStorage siguen siendo más simples.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP