Chapitre 1 : Mode offline
Le mode offline permet à l'application de fonctionner sans connexion internet.
Stratégie
- Stocker localement les données avec IndexedDB (useDb)
- Synchroniser avec le serveur quand la connexion revient
- Gérer les conflits si les données ont changé des deux côtés
Configuration de useDb
- snippet.javascript
import { useDb } from '@cap-rel/smartcommon'; const db = useDb({ name: 'monApp', version: 1, stores: { // Données métier tasks: 'id, ref, label, status, synced, updatedAt', // File d'attente des modifications syncQueue: 'id++, action, entity, entityId, data, createdAt' } });
Hook personnalisé pour le offline
- snippet.javascript
// hooks/useOfflineSync.js import { useState, useEffect } from 'react'; import { useApi, useDb } from '@cap-rel/smartcommon'; export function useOfflineSync() { const api = useApi(); const db = useDb({ name: 'monApp', version: 1, stores: { tasks: 'id, synced', syncQueue: 'id++, action, entity, entityId, data' } }); const [isOnline, setIsOnline] = useState(navigator.onLine); const [isSyncing, setIsSyncing] = useState(false); // Détecter le statut réseau useEffect(() => { const handleOnline = () => { setIsOnline(true); sync(); // Synchroniser automatiquement }; const handleOffline = () => setIsOnline(false); window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); // Synchroniser les données const sync = async () => { if (!navigator.onLine || isSyncing) return; setIsSyncing(true); try { // 1. Envoyer les modifications locales const queue = await db.syncQueue.toArray(); for (const item of queue) { try { if (item.action === 'create') { await api.private.post(item.entity, { json: item.data }); } else if (item.action === 'update') { await api.private.put(`${item.entity}/${item.entityId}`, { json: item.data }); } else if (item.action === 'delete') { await api.private.delete(`${item.entity}/${item.entityId}`); } // Supprimer de la queue await db.syncQueue.delete(item.id); } catch (err) { console.error('Sync error:', err); } } // 2. Récupérer les données fraîches du serveur const data = await api.private.get('tasks').json(); // 3. Mettre à jour la base locale await db.tasks.clear(); await db.tasks.bulkAdd(data.tasks.map(t => ({ ...t, synced: true }))); } finally { setIsSyncing(false); } }; // Ajouter une action à la queue const queueAction = async (action, entity, entityId, data) => { await db.syncQueue.add({ action, entity, entityId, data, createdAt: Date.now() }); }; return { db, isOnline, isSyncing, sync, queueAction }; }
Utilisation dans un composant
- snippet.javascript
import { useEffect } from 'react'; import { Page, Block, List, ListItem, Spinner, Tag } from '@cap-rel/smartcommon'; import { useApi, useStates } from '@cap-rel/smartcommon'; import { useOfflineSync } from '../../hooks/useOfflineSync'; export const TasksPage = () => { const api = useApi(); const { db, isOnline, isSyncing, sync, queueAction } = useOfflineSync(); const st = useStates({ initialStates: { tasks: [], loading: true } }); useEffect(() => { loadTasks(); }, []); const loadTasks = async () => { st.set('loading', true); try { if (isOnline) { // En ligne : charger depuis l'API const data = await api.private.get('tasks').json(); // Sauvegarder localement await db.tasks.clear(); await db.tasks.bulkAdd(data.tasks.map(t => ({ ...t, synced: true }))); st.set('tasks', data.tasks); } else { // Hors ligne : charger depuis IndexedDB const localTasks = await db.tasks.toArray(); st.set('tasks', localTasks); } } catch (err) { // Erreur réseau : utiliser les données locales const localTasks = await db.tasks.toArray(); st.set('tasks', localTasks); } finally { st.set('loading', false); } }; const createTask = async (data) => { // Créer localement avec un ID temporaire const tempId = 'temp_' + Date.now(); const newTask = { ...data, id: tempId, synced: false }; await db.tasks.add(newTask); st.set('tasks', [...st.get('tasks'), newTask]); if (isOnline) { try { const result = await api.private.post('tasks', { json: data }).json(); // Remplacer l'ID temporaire par le vrai ID await db.tasks.update(tempId, { id: result.id, synced: true }); } catch (err) { // Ajouter à la queue pour sync ultérieure await queueAction('create', 'tasks', tempId, data); } } else { await queueAction('create', 'tasks', tempId, data); } }; return ( <Page title="Tâches"> {/* Indicateur de statut */} <Block> <div className="flex items-center gap-2"> <Tag color={isOnline ? 'green' : 'red'}> {isOnline ? 'En ligne' : 'Hors ligne'} </Tag> {isSyncing && <Spinner size="sm" />} </div> </Block> {/* Liste */} <Block> <List> {st.get('tasks').map(task => ( <ListItem key={task.id} title={task.label} subtitle={!task.synced ? '⏳ En attente de sync' : null} /> ))} </List> </Block> </Page> ); };
Gestion des conflits
- snippet.javascript
const resolveConflict = async (localData, serverData) => { // Stratégie simple : le plus récent gagne if (localData.updatedAt > serverData.updatedAt) { // Envoyer les données locales au serveur await api.private.put(`tasks/${localData.id}`, { json: localData }); } else { // Remplacer localement par les données serveur await db.tasks.put(serverData); } };
Points clés à retenir
- useDb pour le stockage IndexedDB
- Détecter le statut réseau avec navigator.onLine
- Queue de synchronisation pour les actions offline
- Synchroniser quand la connexion revient
- Gérer les conflits selon une stratégie définie
← Retour au module | Chapitre suivant : Internationalisation →