</> HTML5Advent
ENFRESDEITPT

// apis

Tutorial IndexedDB: costruire un'app di appunti che funziona offline

Un tutorial IndexedDB passo dopo passo. Aprire un database, creare un object store, aggiungere e leggere record, interrogare per indice, eliminare voci e gestire gli upgrade di versione, il tutto in una piccola app di appunti eseguibile nel browser.

Una persona in piedi davanti a rack di server con pareti di vetro in un data center, con un portatile aperto in mano, illuminazione blu e loghi visibili sui pannelli di vetro

La guida concettuale spiega cos'e IndexedDB e quando usarlo. Questo tutorial costruisce una piccola applicazione di appunti da zero per mostrare ogni passaggio: aprire un database, creare uno store, scrivere record, rileggerli, interrogare per indice ed eliminare voci.

Tutto gira nel browser. Nessun build tool, nessun server, nessuna dipendenza. Copiate ogni blocco in un unico file HTML e apritelo.

Passo 1: aprire il database

Ogni interazione con IndexedDB inizia con indexedDB.open(). Si passano un nome e un numero di versione. Se il database non esiste ancora, o se la versione e superiore a quella su disco, il browser emette upgradeneeded.

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('notes-app', 1);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('notes')) {
        const store = db.createObjectStore('notes', {
          keyPath: 'id',
          autoIncrement: true,
        });
        store.createIndex('by_tag', 'tag', { unique: false });
        store.createIndex('by_date', 'createdAt', { unique: false });
      }
    };

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

Alcune cose da notare. keyPath: 'id' dice a IndexedDB quale proprieta di ogni oggetto e la chiave primaria. autoIncrement: true fa generare la chiave al database. Le due chiamate createIndex permettono di interrogare gli appunti per tag o per data in seguito, senza scorrere ogni record.

Passo 2: aggiungere un record

La scrittura in IndexedDB avviene sempre all'interno di una transazione. Se ne apre una sull'object store necessario, si ottiene un riferimento allo store e si chiama add() o put().

async function addNote(db, text, tag) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readwrite');
    const store = tx.objectStore('notes');
    const record = {
      text,
      tag: tag || 'general',
      createdAt: new Date().toISOString(),
    };
    const request = store.add(record);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

add() lancia un errore se un record con la stessa chiave esiste gia. put() lo sovrascrive. Per record nuovi, add() e piu sicuro perche intercetta i duplicati accidentali.

Passo 3: leggere tutti i record

Per leggere tutto cio che contiene uno store, si apre una transazione readonly e si chiama getAll():

async function getAllNotes(db) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readonly');
    const store = tx.objectStore('notes');
    const request = store.getAll();
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

Se serve un solo record e se ne conosce la chiave, usare store.get(key). Restituisce un singolo oggetto anziche un array.

Primo piano di un editor di codice a tema scuro su un monitor, che mostra codice di configurazione Ruby con evidenziazione sintattica verde e bianca, un albero di file a sinistra e numeri di riga a destra
Scrivere codice in un editor scuro. IndexedDB segue lo stesso schema di richieste e callback visibile nella maggior parte delle API del browser, qui avvolto in promise per leggibilita.

Passo 4: interrogare per indice

Gli indici permettono di trovare record senza scorrere l'intero store. Per ottenere tutti gli appunti con tag "work":

async function getNotesByTag(db, tag) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readonly');
    const store = tx.objectStore('notes');
    const index = store.index('by_tag');
    const request = index.getAll(tag);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

Si puo anche usare un key range per interrogare un intervallo di valori. Per esempio, per ottenere gli appunti creati dopo una certa data:

const range = IDBKeyRange.lowerBound('2026-01-01T00:00:00.000Z');
const request = store.index('by_date').getAll(range);

I quattro costruttori di range sono lowerBound, upperBound, bound (entrambi gli estremi) e only (corrispondenza esatta). Ognuno accetta un booleano opzionale per escludere il valore limite.

Passo 5: aggiornare un record

L'aggiornamento usa put(). Bisogna includere la proprieta chiave (id nel nostro schema) perche IndexedDB sappia quale record sovrascrivere:

async function updateNote(db, id, newText) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readwrite');
    const store = tx.objectStore('notes');
    const getReq = store.get(id);
    getReq.onsuccess = () => {
      const note = getReq.result;
      if (!note) { reject(new Error('Not found')); return; }
      note.text = newText;
      const putReq = store.put(note);
      putReq.onsuccess = () => resolve();
      putReq.onerror = () => reject(putReq.error);
    };
    getReq.onerror = () => reject(getReq.error);
  });
}

Passo 6: eliminare un record

Chiamare store.delete(key) all'interno di una transazione readwrite:

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

Per cancellare tutti i record di uno store, usare store.clear().

Passo 7: gestire gli upgrade di versione

Quando serve aggiungere un nuovo indice o un nuovo object store dopo che gli utenti hanno gia la versione 1, incrementare il numero di versione e gestire la migrazione in onupgradeneeded:

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  const oldVersion = event.oldVersion;

  if (oldVersion < 1) {
    const store = db.createObjectStore('notes', {
      keyPath: 'id',
      autoIncrement: true,
    });
    store.createIndex('by_tag', 'tag', { unique: false });
    store.createIndex('by_date', 'createdAt', { unique: false });
  }

  if (oldVersion < 2) {
    const tx = event.target.transaction;
    const store = tx.objectStore('notes');
    store.createIndex('by_priority', 'priority', { unique: false });
  }
};

Controllare event.oldVersion per eseguire solo le migrazioni che l'utente non ha ancora visto. Un utente che passa dalla versione 0 (nessun database) esegue entrambi i blocchi. Un utente che passa dalla versione 1 esegue solo il secondo.

Mettere tutto insieme

Ecco uno script minimale che esercita ogni funzione descritta sopra:

(async () => {
  const db = await openDB();

  const id = await addNote(db, 'Buy groceries', 'personal');
  console.log('Added note with id:', id);

  const all = await getAllNotes(db);
  console.log('All notes:', all);

  const tagged = await getNotesByTag(db, 'personal');
  console.log('Personal notes:', tagged);

  await updateNote(db, id, 'Buy groceries and cook dinner');
  console.log('Updated note', id);

  await deleteNote(db, id);
  console.log('Deleted note', id);
})();

Aprire la console del browser per vedere l'output. Ogni operazione e asincrona, ogni scrittura passa attraverso una transazione, e i dati sopravvivono al ricaricamento della pagina senza alcun server.

Errori comuni

ErroreCosa succedeRimedio
Creare uno store fuori da onupgradeneededInvalidStateErrorTutte le modifiche allo schema vanno in onupgradeneeded
Usare await dentro il callback di una transazioneLa transazione si chiude automaticamente prima che l'await si risolvaMantenere tutte le operazioni sullo store sincrone entro un tick della transazione, oppure aprire una nuova transazione dopo l'await
Dimenticare di incrementare il numero di versioneonupgradeneeded non si attiva maiIncrementare l'intero di versione ogni volta che si cambia lo schema
Passare una stringa dove la chiave richiede un numeroRecord non trovatoFar corrispondere il tipo: se autoIncrement genera numeri, interrogare con un numero

IndexedDB e prolisso, ma ogni pezzo ha uno scopo: la versione protegge lo schema, le transazioni proteggono i dati e gli indici mantengono le letture veloci. Una volta che questi sette passi diventano routine, avrete tutto il necessario per memorizzare dati strutturati sul client, che sia per il supporto offline, il caching o il mantenimento di stato oltre la sessione.

Domande frequenti

Come si apre un database IndexedDB?
Si chiama indexedDB.open(nome, versione). Il primo argomento e una stringa che identifica il database, il secondo un intero di versione. La chiamata restituisce un IDBOpenDBRequest. Si ascolta il suo evento onsuccess per ottenere l handle del database e onupgradeneeded per creare o modificare gli object store quando cambia la versione.
Che cos e onupgradeneeded in IndexedDB?
onupgradeneeded si attiva quando il browser non ha ancora un database con quel nome, o quando il numero di versione passato e superiore a quello su disco. E l unico punto in cui si possono creare o eliminare object store e indici. Del codice che tenta di farlo al di fuori di questo evento lancia un errore.
IndexedDB funziona offline?
Si. IndexedDB memorizza i dati nel browser senza bisogno di rete. Associato a un service worker che mette in cache HTML, CSS e JavaScript, si ottiene un applicazione completamente offline. I dati persistono tra le sessioni finche l utente non cancella i dati del sito o la pressione di archiviazione non spinge il browser a eliminarli.
In cosa si differenzia IndexedDB da localStorage?
localStorage e sincrono, memorizza solo stringhe ed e limitato a circa 5 MB. IndexedDB e asincrono, memorizza dati strutturati inclusi Blob e ArrayBuffer, supporta indici e transazioni e puo contenere centinaia di megabyte. Usare localStorage per pochi piccoli flag; usare IndexedDB per tutto cio che e piu grande o strutturato.