Local-first data in Terreno
Terreno's default frontend data path is local-first: the on-device store is what your UI reads and writes, and the server reconciles changes asynchronously. This is a different mental model from request/response fetching with RTK Query.
What local-first means here
In @terreno/syncdb, a TinyBase MergeableStore on the device is the UI source of truth. When a user taps "save," the mutation applies to local state immediately and lands in a durable outbox. The client then pushes the change to @terreno/api over the sync protocol (socket deltas with HTTP snapshot catch-up). The server remains the authority for permissions, validation, and conflict resolution — but the UI does not wait on the network for every write.
There is no server-first mode and no opt-in flag: if you use syncdb, you adopt this model for every synced collection.
Mental model shift
| Server-first (RTK Query) | Local-first (syncdb) |
|---|---|
| UI reads from cache filled by HTTP | UI reads from the local store |
| Writes call the API, then invalidate/refetch | Writes apply locally, then reconcile |
| Loading spinners track request lifecycle | Sync status tracks outbox + connectivity |
| Optimistic updates are manual and fragile | Optimistic state is the default path |
| Conflicts are rare / ignored | Conflicts are explicit and must be resolved |
Think apply locally, then reconcile instead of request, wait, update cache.
What you stop doing
- Manual optimistic updates —
onQueryStartedpatches, temporary IDs, and rollback logic for writes. - Write-level loading spinners —
isLoadingon mutations for "saving…" UX; the row updates instantly. - Refetch orchestration —
invalidatesTags,refetch(), and list/detail cache coordination after mutations. - Treating the network as the read path — list screens blocked on
isLoadingfor every navigation.
RTK Query remains appropriate for non-synced endpoints: /auth/me, admin RPC, AI routes, and anything that should stay request/response.
What you start doing
- Conflict resolution UI — When the server rejects a mutation (409 /
sync:nack), the user must choose a strategy (useServerorkeepMine). UseConflictSheetfrom@terreno/uior build your own withuseConflicts. - Sync-status UX — Surface offline state, queued mutations, and drain progress with
useSyncStatus(and optionallySyncStatusBanner). - Encryption at rest (web) — Web persistence uses AES-GCM via the default
createServerKeyProvider; understand key fetch andonDecryptFailurefor support flows. - Key lifecycle on logout — Call
syncDb.stop()and considerwipeLocalDatawhen the authenticated user changes so one account does not read another's local store. - Backend sync registration — Every synced collection needs
syncPlugin,isDeletedPlugin, and asyncblock onmodelRouter, plusSyncAppandRealtimeAppon the server.
Tradeoffs
Gains: instant UI, offline writes with a durable outbox, simpler screen code, and one write path on the server (sync and REST share modelRouter).
Costs: larger conceptual surface (conflicts, sync status, local storage), mandatory replica-set MongoDB for realtime, and migration work for existing RTK Query screens.
Server-first fetching is simpler when you only need online CRUD with no offline requirement — but Terreno's blessed path for collection data is local-first.
Related docs
- syncdb reference — API ground truth
- Migrate from RTK to syncdb — step-by-step migration
- Authentication architecture — Better Auth + socket sessions