Pourquoi IndexedDB mérite votre attention

Quand une interface web doit conserver des données côté navigateur, beaucoup de développeurs commencent par localStorage. C’est simple, synchrone, disponible partout, et suffisant pour stocker un thème sombre ou un petit jeton non sensible. Mais dès que l’on veut stocker des objets plus nombreux, des brouillons, un cache applicatif, des préférences complexes ou des données accessibles hors ligne, localStorage devient vite un mauvais outil.

IndexedDB répond à un besoin différent : stocker localement des données structurées, indexées, persistantes et manipulables de façon asynchrone. C’est une base de données embarquée dans le navigateur. Elle n’a pas l’ergonomie d’une API moderne au premier abord, mais avec une petite couche TypeScript, elle devient beaucoup plus agréable à utiliser.

Cet article complète naturellement les sujets liés au cache frontend stale-while-revalidate, aux Service Workers et au progressive enhancement. IndexedDB n’est pas un substitut à tout cela, mais une brique de stockage locale très utile dans une architecture frontend robuste.

IndexedDB, localStorage et Cache Storage : ne pas confondre

Avant d’écrire du code, clarifions les rôles.

localStorage stocke des chaînes de caractères sous forme clé-valeur. Son API est synchrone, donc bloquante pour le thread principal. Elle convient pour des données minuscules, rarement modifiées.

Cache Storage, souvent utilisé avec les Service Workers, stocke des requêtes et des réponses HTTP. Il est idéal pour mettre en cache des assets, des pages ou certaines réponses réseau.

IndexedDB, lui, stocke des objets JavaScript structurés. On peut créer des magasins, appelés object stores, définir des clés primaires, ajouter des index et faire des lectures ou écritures asynchrones. C’est le bon choix pour des données applicatives locales : brouillons d’articles, paniers, listes de tâches, historique récent, préférences avancées, données synchronisées plus tard avec une API.

Pour une documentation de référence, la page MDN sur IndexedDB reste une bonne ressource externe : IndexedDB API sur MDN.

Définir un modèle de données typé

Prenons un exemple concret : une application de prise de notes qui doit permettre de conserver des brouillons localement, même si l’utilisateur ferme l’onglet.

On commence par définir le type métier :

type DraftNote = {
  id: string;
  title: string;
  content: string;
  updatedAt: number;
  synced: boolean;
};

On pourrait manipuler IndexedDB directement partout dans l’application, mais ce serait rapidement répétitif. L’objectif est plutôt de centraliser l’ouverture de la base, puis d’exposer quelques fonctions lisibles : saveDraft, getDraft, listDrafts, deleteDraft.

Ouvrir une base IndexedDB proprement

L’API native repose sur des événements (onsuccess, onerror, onupgradeneeded) plutôt que sur des promesses. On peut l’envelopper dans une fonction plus moderne.

const DB_NAME = "formation-dev-web";
const DB_VERSION = 1;
const DRAFT_STORE = "draftNotes";

function openDatabase(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

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

      if (!db.objectStoreNames.contains(DRAFT_STORE)) {
        const store = db.createObjectStore(DRAFT_STORE, {
          keyPath: "id"
        });

        store.createIndex("by-updated-at", "updatedAt");
        store.createIndex("by-synced", "synced");
      }
    };

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

La partie importante est onupgradeneeded. Elle est appelée lors de la création de la base ou lors d’un changement de version. C’est ici que l’on définit la structure : magasins, clés primaires, index.

Le paramètre keyPath: "id" indique que chaque note utilise sa propriété id comme clé primaire. Les index updatedAt et synced permettront ensuite de trier ou filtrer plus efficacement.

Écrire une fonction générique de transaction

IndexedDB fonctionne avec des transactions. Une transaction peut être en lecture seule (readonly) ou en lecture-écriture (readwrite). Pour éviter de répéter le même code, créons un petit utilitaire.

async function withStore<T>(
  mode: IDBTransactionMode,
  callback: (store: IDBObjectStore) => IDBRequest<T>
): Promise<T> {
  const db = await openDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction(DRAFT_STORE, mode);
    const store = transaction.objectStore(DRAFT_STORE);
    const request = callback(store);

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

    transaction.oncomplete = () => db.close();
    transaction.onerror = () => reject(transaction.error);
  });
}

Cette fonction n’est pas parfaite pour tous les cas avancés, mais elle suffit pour une première abstraction claire. Elle ouvre la base, crée une transaction, exécute une opération sur le store, puis transforme le résultat en promesse.

Sauvegarder et relire un brouillon

On peut maintenant écrire des fonctions métier beaucoup plus simples.

export async function saveDraft(note: DraftNote): Promise<string> {
  return withStore<string>("readwrite", (store) => store.put(note));
}

export async function getDraft(id: string): Promise<DraftNote | undefined> {
  return withStore<DraftNote | undefined>("readonly", (store) => store.get(id));
}

export async function deleteDraft(id: string): Promise<void> {
  await withStore<undefined>("readwrite", (store) => store.delete(id));
}

put insère ou remplace une entrée. C’est pratique pour un brouillon, car l’utilisateur peut sauvegarder plusieurs fois la même note. Si l’on voulait refuser l’écrasement d’une donnée existante, on utiliserait plutôt add.

Voici un exemple d’utilisation :

await saveDraft({
  id: crypto.randomUUID(),
  title: "Plan de formation React",
  content: "Commencer par les composants, puis les hooks.",
  updatedAt: Date.now(),
  synced: false
});

Le stockage local devient alors une capacité normale de l’application, plutôt qu’un détail dispersé dans les composants.

Lister les données avec un curseur

Pour récupérer toutes les notes, IndexedDB utilise souvent des curseurs. Ils permettent de parcourir progressivement les entrées d’un store ou d’un index.

export async function listDrafts(): Promise<DraftNote[]> {
  const db = await openDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction(DRAFT_STORE, "readonly");
    const store = transaction.objectStore(DRAFT_STORE);
    const index = store.index("by-updated-at");
    const request = index.openCursor(null, "prev");
    const notes: DraftNote[] = [];

    request.onsuccess = () => {
      const cursor = request.result;

      if (!cursor) {
        resolve(notes);
        return;
      }

      notes.push(cursor.value);
      cursor.continue();
    };

    request.onerror = () => reject(request.error);
    transaction.oncomplete = () => db.close();
    transaction.onerror = () => reject(transaction.error);
  });
}

Le second argument de openCursor, ici "prev", parcourt l’index dans l’ordre décroissant. Les notes les plus récentes remontent donc en premier.

Dans une application réelle, il faudrait aussi prévoir une pagination ou une limite, surtout si le volume de données peut devenir important. IndexedDB peut stocker beaucoup plus qu’un localStorage, mais ce n’est pas une raison pour tout charger en mémoire à chaque affichage.

Intégrer IndexedDB dans React

Dans React, il faut éviter de mélanger la logique de stockage avec le rendu. Un hook peut servir de frontière propre.

import { useEffect, useState } from "react";

export function useDrafts() {
  const [drafts, setDrafts] = useState<DraftNote[]>([]);
  const [loading, setLoading] = useState(true);

  async function refresh() {
    const nextDrafts = await listDrafts();
    setDrafts(nextDrafts);
  }

  useEffect(() => {
    let ignore = false;

    listDrafts()
      .then((result) => {
        if (!ignore) setDrafts(result);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, []);

  return {
    drafts,
    loading,
    refresh
  };
}

Cette approche reste volontairement simple. Pour des flux plus complexes, on peut combiner IndexedDB avec une stratégie de cache applicatif, une synchronisation réseau, voire une machine à états comme dans l’article sur les machines à états en TypeScript.

Gérer les erreurs et les migrations

Le point le plus sous-estimé avec IndexedDB est la migration de schéma. Dès que vous changez la structure de vos données, vous devez augmenter DB_VERSION et gérer les transformations nécessaires dans onupgradeneeded.

Exemple : ajouter un nouveau store pour des préférences utilisateur.

const DB_VERSION = 2;
const PREFERENCES_STORE = "preferences";

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

  if (!db.objectStoreNames.contains(DRAFT_STORE)) {
    db.createObjectStore(DRAFT_STORE, { keyPath: "id" });
  }

  if (!db.objectStoreNames.contains(PREFERENCES_STORE)) {
    db.createObjectStore(PREFERENCES_STORE, { keyPath: "key" });
  }
};

Il faut éviter de supprimer brutalement un store ou de changer une clé primaire sans stratégie. Les utilisateurs peuvent conserver des versions anciennes de votre application dans un onglet ouvert, ou revenir après plusieurs semaines. Une migration doit donc être pensée comme du code de production durable.

Bonnes pratiques à retenir

IndexedDB est puissant, mais il demande de la discipline.

Premièrement, ne l’utilisez pas pour des secrets sensibles. Les données stockées côté navigateur restent accessibles dans l’environnement utilisateur. Pour les jetons, données personnelles ou informations critiques, appliquez une vraie stratégie de sécurité côté serveur.

Deuxièmement, centralisez l’accès à la base. Si chaque composant ouvre ses propres transactions avec sa propre logique, vous obtiendrez rapidement une architecture difficile à tester.

Troisièmement, versionnez explicitement votre schéma. Une base locale évolue dans le temps, comme une base SQL ou un modèle d’API. La différence est que vous ne contrôlez pas directement le moment où chaque navigateur exécutera la migration.

Enfin, gardez une stratégie de synchronisation claire. Une donnée locale peut être en avance, en retard ou en conflit avec le serveur. Un champ comme synced, updatedAt ou serverVersion peut vous aider à rendre ces états explicites.

Conclusion

IndexedDB n’est pas l’API la plus élégante du web, mais c’est l’une des plus utiles dès que l’on veut construire des interfaces plus résilientes. Avec une couche TypeScript simple, des transactions centralisées et une vraie réflexion sur les migrations, elle permet de stocker des données applicatives locales sans tomber dans les limites de localStorage.

Pour des applications de prise de notes, de formation, de datavisualisation, de PWA ou d’édition de contenu, IndexedDB devient une brique d’architecture frontend très concrète : discrète, asynchrone, persistante, et parfaitement complémentaire d’un cache réseau bien conçu.