Reactivity
No refresh button anywhere
Overview
Convex's reactivity is the default — `useQuery(api.X.Y, args)` returns a value that updates whenever the server-side data those args touched changes. There is no equivalent of React Query's refetch call anywhere in the app. We don't poll. We don't pull-to-refresh.
How it works
Convex tracks which rows + indexes each query reads. Any write that mutates one of those rows / indexes invalidates the query for every subscriber and re-runs it server-side, pushing new results to the client.
Client-side, `useQuery` returns `undefined` until the first result lands. Every screen has a LoadingScreen branch for `undefined` so the UI doesn't flash empty state on cold queries.
Mutations are optimistic only when we opt in. By default we wait for the server round-trip and let the reactive update repaint — the latency is usually 30–100ms on a warm cache, fast enough that we don't bother with optimism for most actions.
Cross-screen updates are free. Marking a goal done on the Focus screen updates the Missions tab the moment you back out, without any explicit cache invalidation, because both screens subscribe to the same underlying query.
Subscriptions are paused when the app is backgrounded, which keeps the websocket from chewing battery, and resumed on foreground.
Key decisions
No optimistic updates by default
We let the server be the source of truth. Optimism complicates the rollback story — every optimistic write needs a 'what if it failed' branch. Convex round-trips are fast enough that the optimism doesn't buy enough to justify the complexity. We opt in only for the OliviumToast.
LoadingScreen for every undefined query
Every top-level screen checks `query === undefined` first and renders a shared `LoadingScreen`. This is more boilerplate than a Suspense boundary would be, but it's deterministic — the dev knows exactly where the loading branch is.