Stockage de données

SmartCommon propose plusieurs solutions pour le stockage de données côté client :

  • useGlobalStates : état global avec persistance automatique (localStorage/sessionStorage)
  • useStates : état local réactif avec path notation
  • useDb : base de données IndexedDB via Dexie
  • useCachedQuery : cache de données avec stratégies (NetworkFirst, CacheFirst, SWR)

Pour la synchronisation offline complète, voir Synchronisation offline (useSyncClient).

Documentation IndexedDB Documentation Dexie Documentation Redux Toolkit

useGlobalStates (recommandé)

Le hook useGlobalStates fournit un état global avec persistance automatique. C'est la méthode recommandée pour les données utilisateur (session, préférences, etc.).

Utilisation basique

import { useGlobalStates } from '@cap-rel/smartcommon';

export const MyComponent = () => {
  const gst = useGlobalStates();

  // Lire une valeur
  const user = gst.get('user');

  // Écrire dans localStorage (persistant)
  const login = (userData) => {
    gst.local.set('user', userData);
  };

  // Écrire dans sessionStorage (session uniquement)
  const setTempData = (data) => {
    gst.session.set('tempData', data);
  };

  // Supprimer une valeur
  const logout = () => {
    gst.unset('user');
  };

  return (
    <div>
      {user ? `Bonjour ${user.name}` : 'Non connecté'}
    </div>
  );
};

Méthodes disponibles

Méthode Description
gst.get(path) Récupère une valeur par son chemin
gst.local.set(path, value) Stocke dans localStorage (persistant)
gst.session.set(path, value) Stocke dans sessionStorage (session)
gst.unset(path) Supprime une valeur
gst.values Objet contenant toutes les valeurs

Path notation

Vous pouvez accéder aux données imbriquées avec la notation par points :

// Définir des données imbriquées
gst.local.set('user.preferences.theme', 'dark');
gst.local.set('user.preferences.language', 'fr');

// Lire des données imbriquées
const theme = gst.get('user.preferences.theme'); // 'dark'
const prefs = gst.get('user.preferences'); // { theme: 'dark', language: 'fr' }

useStates (état local)

Le hook useStates fournit un état local réactif avec la même API que useGlobalStates.

import { useStates } from '@cap-rel/smartcommon';

export const MyForm = () => {
  const st = useStates({
    initialStates: {
      name: '',
      email: '',
      errors: {}
    },
    debug: true // affiche les changements dans la console
  });

  const handleChange = (field, value) => {
    st.set(field, value);
  };

  const validate = () => {
    if (!st.get('name')) {
      st.set('errors.name', 'Le nom est requis');
    }
  };

  return (
    <form>
      <Input
        value={st.get('name')}
        onChange={(e) => handleChange('name', e.target.value)}
        error={st.get('errors.name')}
      />
    </form>
  );
};

Méthodes disponibles

Méthode Description
st.get(path) Récupère une valeur
st.set(path, value) Définit une valeur
st.unset(path) Supprime une valeur
st.values Objet contenant toutes les valeurs
st.states Alias de values

Manipulation de tableaux

const st = useStates({ initialStates: { items: [] } });

// Ajouter un élément (push)
st.set('items[]', { id: 1, name: 'Item 1' });

// Modifier un élément par index
st.set('items[0].name', 'Item modifié');

// Supprimer un élément par index
st.unset('items[0]');

useCachedQuery (cache avec stratégies)

Le hook useCachedQuery permet de mettre en cache les données API dans IndexedDB avec des stratégies configurables.

import { useCachedQuery } from '@cap-rel/smartcommon';

const ItemsList = () => {
  const db = useMemo(() => new Dexie('myApp'), []);

  const {
    data,          // données (depuis cache ou réseau)
    isLoading,     // chargement en cours
    isFromCache,   // true si les données viennent du cache
    isStale,       // true si les données sont périmées
    error,         // erreur éventuelle
    lastFetch,     // timestamp du dernier fetch
    refetch,       // forcer un nouveau fetch
    invalidate,    // invalider le cache
  } = useCachedQuery({
    db,
    store: 'cache',         // nom du store IndexedDB (schéma: 'key')
    key: 'items-list',      // clé de cache unique
    fetchFn: () => api.get('items'),  // fonction de récupération
    strategy: 'networkFirst',         // stratégie de cache
    ttl: 3600000,           // durée de vie du cache (1h par défaut)
    staleTime: 60000,       // durée avant que le cache soit "stale" (1min par défaut)
    enabled: true,          // activer/désactiver le fetch
  });

  if (isLoading) return <Spinner />;

  return (
    <List>
      {data?.map(item => (
        <ListItem key={item.id}>{item.label}</ListItem>
      ))}
    </List>
  );
};

Stratégies disponibles

Stratégie Constante Comportement
networkFirst NETWORK_FIRST Réseau d'abord, cache en fallback (défaut)
cacheFirst CACHE_FIRST Cache d'abord, réseau si absent ou expiré
swr STALE_WHILE_REVALIDATE Retourne le cache immédiatement, revalide en arrière-plan
import { CACHE_STRATEGIES } from '@cap-rel/smartcommon';

// Utilisation avec constante
useCachedQuery({
  // ...
  strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE,
});

Base IndexedDB (Dexie)

Pour stocker de grandes quantités de données ou des données structurées complexes, SmartCommon fournit la classe Db (et le hook useDb), basés sur Dexie.

Une base unique, définie dans src/db/

Une application a une seule base locale, déclarée une fois dans src/db/index.js et importée partout où on en a besoin : les hooks métier useDb<Feature>, useReferenceSync, useCachedQuery... Voir Architecture pour l'arborescence complète.

// src/db/index.js
import { Db } from '@cap-rel/smartcommon';

export const db = new Db({
  name: 'myApp',
  version: 1,
  stores: {
    items: '++id, name, category, createdAt',
    categories: '++id, name',
    // Métadonnées de useReferenceSync (clé / valeur)
    syncMeta: 'key'
  }
}).db;

Les composants n'ouvrent pas de base : ils importent db et passent par un hook métier.

// src/db/stores/items/useDbItems.jsx
import { db } from 'src/db';

export const useDbItems = () => ({
  list: () => db.items.toArray(),
  add: (item) => db.items.add(item),
  update: (id, changes) => db.items.update(id, changes),
  remove: (id) => db.items.delete(id),
  byCategory: (category) => db.items.where('category').equals(category).toArray()
});
import { useEffect, useState } from 'react';
import { useDbItems } from 'src/db';

export const ItemsManager = () => {
  const dbItems = useDbItems();
  const [items, setItems] = useState([]);

  useEffect(() => {
    dbItems.list().then(setItems);
  }, []);

  return (/* ... */);
};

Le hook useDb

useDb({ name, version, stores }) construit la même base depuis un composant (instance mémorisée tant que le schéma ne change pas). Il convient à un prototype ou à un composant isolé. Dès que deux parties de l'application partagent des données, utilisez la base de src/db/ : deux appels à useDb avec des schémas différents sur le même nom ouvrent deux versions concurrentes de la même base. useDb ne gère qu'une version de schéma, voir Migrations de schéma.

Migrations de schéma

useDb et la forme new Db({ version, stores }) ne décrivent qu'une version du schéma. Dès qu'une version publiée de l'application a déjà créé la base chez les utilisateurs, un changement de schéma passe par une nouvelle version : on ne modifie jamais une version existante, sinon Dexie refuse d'ouvrir la base (VersionError) sur les appareils déjà installés.

Pour enchaîner plusieurs versions, passez versions à Db dans src/db/index.js. Chaque entrée donne le schéma complet de cette version et, si besoin, une fonction upgrade qui transforme les données existantes :

// src/db/index.js
import { Db } from '@cap-rel/smartcommon';

export const db = new Db({
  name: 'myApp',
  versions: [
    {
      version: 1,
      stores: {
        items: '++id, name, category',
        drafts: '++id'
      }
    },
    {
      // v2 : index sur status, "name" renommé en "label", drafts retiré
      version: 2,
      stores: {
        items: '++id, label, category, status',
        drafts: null
      },
      upgrade: (tx) => tx.items.toCollection().modify((item) => {
        item.label = item.name;
        delete item.name;
        item.status = item.status ?? 'active';
      })
    }
  ]
}).db;

Règles :

  • Les numéros de version sont strictement croissants. Un appareil resté en v1 passe par toutes les étapes jusqu'à la dernière, upgrade comprise.
  • Répétez le schéma complet à chaque version : createdAt et updatedAt ne sont ajoutés automatiquement que sur les stores listés dans la dernière.
  • Un store passé à null est supprimé avec ses données.
  • upgrade reçoit la transaction Dexie de la migration : n'y faites aucun appel réseau, elle se termine dès qu'elle attend autre chose qu'IndexedDB.
  • versions remplace version et stores : ne mélangez pas les deux formes.

Fonctionnalités automatiques

Db et useDb ajoutent automatiquement :

  • createdAt : timestamp de création
  • updatedAt : timestamp de dernière modification
  • Table logs : journal de toutes les opérations (create, update, delete)

Requêtes Dexie

// Tous les items
const all = await db.items.toArray();

// Par clé primaire
const item = await db.items.get(1);

// Filtrer
const filtered = await db.items
  .where('category')
  .equals('electronics')
  .toArray();

// Trier
const sorted = await db.items
  .orderBy('createdAt')
  .reverse()
  .toArray();

// Limiter
const first10 = await db.items.limit(10).toArray();

// Compter
const count = await db.items.count();

Astuce

Pour un cache API avec stratégies automatiques, préférez useCachedQuery. Pour la synchronisation offline complète avec gestion de conflits, utilisez useSyncClient.

Redux (méthode classique)

Si vous préférez utiliser Redux directement, SmartCommon fournit ReduxProvider.

Créer un slice

// src/redux/reducers/sessionSlice.js

import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  data: JSON.parse(sessionStorage.getItem("session")) ?? null
};

const sessionSlice = createSlice({
  name: "session",
  initialState,
  reducers: {
    setSession(state, action) {
      state.data = action.payload;
      sessionStorage.setItem("session", JSON.stringify(action.payload));
    },
    unsetSession(state) {
      state.data = null;
      sessionStorage.removeItem("session");
    }
  },
});

export default sessionSlice.reducer;
export const { setSession, unsetSession } = sessionSlice.actions;

Configurer le store

// src/redux/index.js

import sessionReducer from "./reducers/sessionSlice";
import { combineReducers } from "redux";
import { configureStore } from "@reduxjs/toolkit";

const rootReducer = combineReducers({
  session: sessionReducer
});

export const reduxStore = configureStore({ reducer: rootReducer });
export * from "./reducers/sessionSlice";

Utiliser dans les composants

import { useSelector, useDispatch } from "react-redux";
import { setSession, unsetSession } from "../../../redux";

export const MyComponent = () => {
  const session = useSelector(state => state.session.data);
  const dispatch = useDispatch();

  const login = (data) => dispatch(setSession(data));
  const logout = () => dispatch(unsetSession());

  return (/* ... */);
};

Comparaison des méthodes

Méthode Cas d'usage Persistance
useGlobalStates Session utilisateur, préférences localStorage / sessionStorage
useStates État de formulaire, UI locale Mémoire (non persistant)
useDb Données structurées, stockage local IndexedDB
useCachedQuery Cache API avec stratégies IndexedDB
useSyncClient Synchronisation offline complète IndexedDB
Redux Applications complexes, middleware Configurable

Astuce

Pour la plupart des cas, useGlobalStates et useStates suffisent. Pour un stockage IndexedDB, déclarez la base de l'application dans src/db/ avec Db. Pour le cache de données API, utilisez useCachedQuery. Pour le mode offline complet avec synchronisation, utilisez useSyncClient.

Voir aussi