Offline-first without a repository pattern: just Zustand and a smart merge
A small confession before we start: the project’s architecture docs describe a “repository pattern” that would wrap AsyncStorage and Firestore behind a shared interface. Looking at the actual code, that repository doesn’t exist. What does exist, and works just fine, is simpler than that.
What’s actually there
Foodproof keeps its scan history in a Zustand store, useScanHistoryStore, with the persist middleware backed by AsyncStorage. No Repository class, no shared interface between local storage and Firestore. Just a store, and a service class (ScanHistorySync) that talks directly to Firestore when asked to.
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
}
}
},
That comment in the code sums up the whole philosophy: we already have the data locally, no need to fail the UI if the sync fails.
Why it works without an abstraction
Three simple decisions, taken together, are enough to get correct offline-first behavior:
Write locally first, always. The set() call that updates the Zustand store happens before any network attempt. The user sees their scan show up in the history immediately, network or no network.
Never let a network error bubble up to the UI. Every method on ScanHistorySync is wrapped in a try/catch that swallows the error and returns an empty value instead of letting it propagate. A network outage becomes a silent console.error, not a broken screen.
Merge on load, don’t replace. When the app reloads history from Firestore, it doesn’t blindly overwrite local state with the server response. It merges: Firestore entries are treated as the source of truth, but anything local that hasn’t been synced yet is kept. The comment in the code is explicit about this: it prevents local history from being wiped out if Firestore becomes unreachable at the wrong moment.
What it costs, what it saves
The real trade-off: without a shared Repository interface, if the backend ever needs to change (say goodbye to Firestore for something else), you’d touch the store directly instead of swapping a single implementation behind an interface. On a solo project with one backend expected to stick around for a long time, that risk is easily worth the simplicity gained: no indirection layer to maintain, no interface to evolve alongside two implementations.
The typed error system documented in the specs (NETWORK_ERROR, API_ERROR, and so on) follows the same story: good plan on paper, but the actual code sticks to try/catch and console.error, case by case. It works, because the one thing that really matters here is that an error never blocks the user.
The takeaway: offline-first isn’t primarily an architecture problem. It’s a three-rule discipline: write local first, never let a network error bubble up, merge instead of replace. A repository abstraction can help enforce that discipline consistently across a large project with many entities. On a single store with a single entity, it adds nothing that a clear comment in the code doesn’t already give you.