Offline-first sans repository pattern : juste Zustand et un merge intelligent
Petit aveu avant de commencer : la doc d’architecture du projet parle d’un “repository pattern” qui encapsulerait AsyncStorage et Firestore derrière une interface commune. En regardant vraiment le code, ce repository n’existe pas. Ce qui existe, et qui marche très bien, c’est plus simple que ça.
Ce qui existe vraiment
Foodproof garde son historique de scans dans un store Zustand, useScanHistoryStore, avec le middleware persist branché sur AsyncStorage. Pas de classe Repository, pas d’interface partagée entre le stockage local et Firestore. Juste un store, et une classe de service (ScanHistorySync) qui parle directement à Firestore quand on le lui demande.
addScan: async (scan, userId) => {
set((state) => ({
history: [
scan,
...state.history.filter((s) => s.product.barcode !== scan.product.barcode),
],
}));
if (userId && userId !== "guest") {
try {
await ScanHistorySync.saveScan(userId, scan);
} catch (error) {
console.error(error);
// Don't throw - we already have it locally
}
}
},
Le commentaire dans le code résume toute la philosophie : on a déjà la donnée en local, pas besoin de faire échouer quoi que ce soit côté UI si la synchro rate.
Pourquoi ça marche sans abstraction
Trois décisions simples, prises ensemble, suffisent à obtenir un comportement offline-first correct :
Écrire en local d’abord, toujours. Le set() qui met à jour le store Zustand arrive avant toute tentative réseau. L’utilisateur voit son scan apparaître dans l’historique immédiatement, qu’il y ait du réseau ou pas.
Ne jamais laisser une erreur réseau remonter jusqu’à l’UI. Toutes les méthodes de ScanHistorySync sont enveloppées dans des try/catch qui avalent l’erreur et renvoient une valeur vide plutôt que de la laisser remonter. Une coupure réseau devient un console.error silencieux, pas un écran cassé.
Fusionner au chargement, pas remplacer. Quand l’app recharge l’historique depuis Firestore, elle ne remplace pas bêtement l’état local par la réponse serveur. Elle fusionne : les entrées Firestore sont considérées comme la source de vérité, mais tout ce qui existe en local et n’a pas encore été synchronisé est conservé. Le commentaire dans le code est explicite là-dessus : ça évite que l’historique local soit vidé si Firestore devient inaccessible au mauvais moment.
Ce que ça coûte, ce que ça évite
Le vrai compromis : sans interface Repository partagée, si un jour il faut changer de backend (dire au revoir à Firestore pour autre chose), il faudra toucher le store directement plutôt qu’une seule implémentation derrière une interface. Sur un projet solo avec un seul backend prévu pour longtemps, ce risque est largement acceptable face à la simplicité gagnée : pas de couche d’indirection à maintenir, pas d’interface à faire évoluer en parallèle de deux implémentations.
Le système d’erreurs typées documenté dans les specs (NETWORK_ERROR, API_ERROR, etc.) suit la même histoire : c’est un bon plan sur le papier, mais le code réel se contente de try/catch et de console.error au cas par cas. Ça fonctionne, parce que la seule chose qui compte vraiment ici, c’est qu’une erreur ne bloque jamais l’utilisateur.
Le point à retenir : l’offline-first, ce n’est pas d’abord un problème d’architecture. C’est une discipline à trois règles : écrire local en premier, ne jamais laisser une erreur réseau remonter, fusionner plutôt que remplacer. Une abstraction repository peut aider à appliquer cette discipline de façon uniforme sur un gros projet à plusieurs entités. Sur un store et une entité, elle n’apporte rien qu’un commentaire clair dans le code n’apporte déjà.