Convex Data Fetching Standard
Use this document when adding or refactoring Convex reads in this monorepo.
It does two things:
- records the current audit of
apps/admin - defines the target pattern we should follow, based on the working approach in
ordna
Why This Exists
The admin app currently uses Convex directly and consistently enough to function, but it does not yet have a single enforced strategy for:
- when a read should be reactive
- when a read should be server-only
- when first-paint preloading is justified
- how query results should stay warm across navigation
- how shared session/access data should be centralized
- how backend query cost should be kept under control
Ordna solved this by combining three things:
- a documented decision tree for
useQueryvsfetchQueryvspreloadQuery - thin client/server wrapper modules
- lightweight guardrails so the codebase cannot silently drift back to ad hoc usage
That is the pattern this repo should adopt.
The Mental Model
The goal is not "fetch data, clear it, then refill it."
The goal is:
- keep subscriptions warm
- keep previously-resolved UI stable when that creates a better experience
- let Convex push fresh results when underlying data changes
- avoid dropping back to
undefinedand showing loading states unless that state is actually useful
This is the key distinction from many manual cache-invalidation systems.
With Convex:
useQueryis reactive- Convex automatically tracks the query and updates it when relevant data changes
- a cache provider can preserve query subscriptions across navigation/remounts
- a stable query wrapper can intentionally hold onto the last good value during arg changes
That combination is what reduces flicker.
Canonical Sources
These Convex articles should be treated as the external reference points for this standard:
What each one contributes:
Magic caching- cache subscriptions, not just values
- keep query data warm across navigation
- reduce unnecessary loading flashes in Next.js apps
Help, my app is overreacting!- use a stable query wrapper when default
useQuerybehavior causes distracting loading flashes during arg changes - keep previous resolved data until the next result is ready when that UX is preferable
- use a stable query wrapper when default
Queries that scale- do not confuse frontend caching with backend efficiency
- indexes and bounded reads still matter
Fully Reactive Pagination- pagination can remain reactive
- paginated lists need a deliberate strategy, not ad hoc local state hacks
Audit Summary
What Ordna Is Doing Better
Ordna has an explicit data layer:
- cached client queries via
convex-helpers/react/cache/hooks - a root
ConvexQueryCacheProvider - server helpers that wrap
fetchQueryandpreloadQuerywith auth-token handling - written rules for dashboards vs public pages
- enforcement scripts that block forbidden server-fetching patterns in the wrong places
- context/provider hooks for shared data instead of duplicating the same query in many screens
Representative files:
ordna/apps/app/src/core/providers/root-providers.client.tsxordna/apps/app/src/core/data/client/index.tsordna/apps/app/src/core/data/server/convex.tsordna/apps/app/src/core/data/patterns/README.mdordna/scripts/check-page-server-fetching.mjsordna/scripts/check-app-server-fetching.mjs
Current State In apps/admin
The admin app does not yet implement that layer.
Current observations:
- no
ConvexQueryCacheProvideris present - no
convex-helpers/react/cache/*usage is present - no
fetchQuery,preloadQuery, orusePreloadedQueryusage is present - there is no
src/core/data/clientorsrc/core/data/serverwrapper layer - feature pages import directly from
convex/react
Examples:
apps/admin/src/core/providers/convex-client-provider.tsxcreatesConvexReactClientand wraps auth, but does not add a query cache providerapps/admin/src/features/dashboard/components/dashboard-page.tsxuses rawuseQueryapps/admin/src/features/activity/components/activity-feed-page.tsxcreates many independent subscriptions in one screenapps/admin/src/features/leads/components/lead-detail-page.tsxloads one page through many separate live queries
Audit Signals
Codebase-wide signals from the current repo:
17apps/admin/srcfiles import directly fromconvex/react47useQuery(...)calls exist inapps/admin/src51.collect()calls exist inapps/admin/convex59.filter(...)calls exist inapps/admin/convex
These counts are not automatically bugs, but they show there is no current hot-path discipline.
Concrete Risks In The Current Admin App
1. Query caching strategy is missing
apps/admin/src/core/providers/convex-client-provider.tsx only wraps:
ThemeProviderConvexAuthNextjsProviderTooltipProvider
It does not wrap a cache provider, so client query subscriptions do not stay warm the same way Ordna designed them to.
2. Repeated direct subscriptions on dashboard pages
The activity page currently opens many live reads in one component:
- activity feed
- daily rollups
- routing throughput
- lifecycle aging
- follow-up closure
- conversion cohorts
- session access
- receivables summary
- receivables trend
- dispatch exceptions
That is a lot of live invalidation for one admin screen. Some of this is justified because it is an operational dashboard, but right now there is no wrapper layer or explicit rule for when a query should be live versus point-in-time.
3. Shared session/access data is duplicated
api.viewer.session.getViewerSessionAccess is queried in multiple feature components instead of being centralized behind a provider or shared hook.
Current repeated callsites include:
- leads list/detail
- billing operations
- routing panel
- activity page
- proxy middleware path
That is a classic candidate for a shared access/session hook.
4. Backend read amplification is already visible
There are several places where full collections are loaded and then filtered in memory.
Examples:
apps/admin/convex/system.ts- dashboard summary loads all leads and all follow-ups, then derives counts in memory
apps/admin/convex/billing.ts- billable events are queried and then filtered again in JS
This is acceptable for small tables, but these are exactly the kinds of operational tables that tend to grow and become dashboard hot paths.
5. No fetch-strategy guardrails exist
Ordna added scripts that reject:
fetchQueryinside page files wherepreloadQueryis required- server fetching in the wrong
app/locations
This repo has no equivalent. Even if we align on a standard, nothing currently stops drift.
Standard We Should Follow
Core Principle
Match the fetch mode to the product need:
- use live subscriptions for admin surfaces that benefit from live updates
- use server reads for auth/capability gating and server-only decisions
- use preloading only when first paint actually matters
- centralize shared reads
- keep backend query read sets narrow
UX Principle
Default to stability, not churn.
For admin screens, the best experience is usually:
- no loading flash on revisit
- no loading flash when a filter or tab changes and the previous data is still a useful placeholder
- skeletons only on first load, major layout changes, or genuinely empty unresolved states
Do not treat "show loading whenever query args change" as the default UX.
Decision Tree
1. Admin dashboards and operational screens
Default to cached client queries.
Use:
- cached
useQuery - client components for lists, counts, tables, activity, status panels
Do not preload these by default. They are revisited often, change frequently, and benefit more from warm live data than from server snapshots.
When the query args change because of local UI state such as:
- filters
- tabs
- date ranges
- sort toggles
prefer a stable query wrapper if showing the old data briefly is less disruptive than flashing to a loading state.
Examples in this repo:
- dashboard overview
- activity feed
- leads list
- lead detail operational panels
- follow-up queues
- billing operations
2. Server layouts and pages
Use server reads only for:
- auth checks
- capability checks
- redirects
- route validation
- detail-page existence checks when the result is only needed server-side
If the client component still needs the data reactively, do not fetch it again on the server unless you are preloading it intentionally.
3. First-paint / SEO-sensitive pages
Use preloadQuery plus usePreloadedQuery only when:
- the page is public or shell-critical
- first paint matters
- the preloaded result is actually rendered immediately
This is usually more relevant to apps/web than apps/admin.
For apps/admin, preloading should be the exception, not the default.
4. Shared app-wide state
If the same query is needed in multiple screens, create a provider or context hook instead of repeating useQuery.
Good candidates in apps/admin:
- current viewer session access
- current admin identity summary
- global dashboard filters if they become cross-page
5. Background or low-freshness reads
If the UI does not actually need live updates, prefer a point-in-time read instead of a subscription.
Good candidates:
- export payloads
- one-off previews
- reports
- rarely-changing reference data
Keep them reactive only if the product meaningfully benefits from live updates.
Loading And Skeleton Rules
This is the practical rule set to preserve before deleting ordna.
Show loading / skeletons when
- the page is loading for the first time and there is no prior useful data
- the layout depends on data that does not yet exist
- a new route is entering a materially different state
- showing stale data would be misleading
Do not show loading / skeletons when
- a cached query is remounting during normal navigation
- a filter changes and the previous result is still a reasonable temporary view
- a card is refreshing but its previous resolved state is still useful
- a live subscription is updating in place
Keep previous data visible when
- the user is refining a view rather than entering a wholly different flow
- the query is driven by local controls
- a brief stale view is less harmful than a visible loading flash
This is where a stable query wrapper belongs.
Stable Query Pattern
Convex's default useQuery behavior is correct, but not always the best UX.
When query arguments change, useQuery can briefly return undefined before the new result arrives. For some admin interactions, that is undesirable because it causes:
- card flicker
- table flicker
- repeated "Loading..." panels
- unnecessary page churn
For those cases, use a stable query wrapper inspired by Convex's "Help, my app is overreacting!" pattern:
- the first load may still return
undefined - after a query has resolved once, keep returning the previous value while the next value is loading
- replace the old value only when the new value has actually resolved
Use stable queries for:
- dashboard filters
- date range changes
- search/facet panels
- tabbed operational views
Do not use stable queries when stale data would be dangerous or misleading.
Examples:
- acceptable: keep previous lead table visible while the status filter changes
- acceptable: keep previous KPI card value while a new date window resolves
- not acceptable: keep showing access-controlled content after auth/permission context changed
- not acceptable: keep showing a previous entity record when the route changed to a different entity and that would confuse the operator
Cache Provider Pattern
The cache provider pattern is separate from the stable query pattern.
Use both when needed:
ConvexQueryCacheProvider- keeps subscriptions warm across navigation/remount
- reduces first-paint loading on revisits and route movement
- stable query wrapper
- reduces
undefinedflashes during argument changes inside an already-mounted screen
- reduces
These solve related but different problems.
Required Client Pattern
We should adopt the Ordna shape in admin:
A. Add a client data wrapper
Create an app-local client barrel, for example:
apps/admin/src/core/data/client/index.ts
That module should:
- export
useQuery - export
useMutation - export
useAction - export
useConvex - export
usePreloadedQuery - export a stable-query hook for no-flicker cases
- become the only allowed import path for client Convex hooks in
src/*
Once caching is added, useQuery in that wrapper should come from convex-helpers/react/cache/hooks, not directly from convex/react.
Recommended shape:
useQuery- default cached reactive query
useStableQuery- cached reactive query that preserves the last resolved value through arg-change loading states
useMutationuseActionuseConvexusePreloadedQuery
B. Add a cache provider
When we are ready to implement the Ordna pattern fully, add:
convex-helpersConvexQueryCacheProvider
The provider should live inside the existing Convex auth provider in apps/admin/src/core/providers/convex-client-provider.tsx.
C. Stop importing convex/react directly in feature code
Feature components should import from the wrapper layer, not from the library directly.
That gives us one place to:
- change caching behavior
- add debug hooks
- enforce loading conventions
- expose stable-query behavior deliberately
- swap patterns without touching every feature page
Required Server Pattern
Create an app-local server barrel, for example:
apps/admin/src/core/data/server/index.ts
It should wrap:
fetchQuerypreloadQuery
and own token/auth concerns for server-side reads.
Recommended helpers:
fetchQueryWithAuthfetchQueryWithOptionalAuthpreloadQueryWithAuthpreloadQueryWithOptionalAuth
This should mirror the successful Ordna pattern rather than scattering raw fetchQuery usage later.
Required Page Rules
Rule 1
Dashboards use cached client queries by default.
Rule 2
Server pages/layouts should only fetch data when they need a server-only decision.
Rule 3
Do not both fetchQuery and preloadQuery the same query in the same request path.
Choose one:
- server-only read
- preloaded client handoff
Rule 4
Use "skip" whenever query arguments are not ready.
Rule 5
If a shared provider hook exists, do not duplicate the underlying query locally.
Rule 6
If the only reason a component shows Loading... is that query arguments changed, prefer a stable query or preserved prior data instead of a visible loading reset.
Rule 7
Do not clear cards/tables to empty state during routine reactive refreshes unless correctness requires it.
Backend Query Rules
Frontend caching will not fix expensive Convex functions. We need the backend rules too.
1. Avoid scan-plus-filter hot paths
Do not treat .collect() then JS .filter(...) as the default for operational tables.
Prefer:
withIndex(...)- narrower query shapes
- dedicated summary/digest queries
- pagination
2. Keep dashboard payloads intentionally shaped
Dashboard queries should return exactly the fields the UI needs.
If a list or KPI reads large source records but only needs a digest, create a digest query or summary table.
3. Be careful with reactive aggregate queries
Counts, totals, and queue summaries can become expensive when they scan broad tables reactively.
For high-value admin KPIs, prefer:
- indexed reads
- precomputed summaries
- snapshot tables updated by write paths or scheduled jobs
4. Do not widen read sets accidentally
If a document is widely read, avoid frequently updating unrelated fields on the same document if that will invalidate large numbers of subscriptions.
5. Treat Date.now() in reactive queries as a smell
Time-based bucket logic inside live queries can increase invalidation and cache churn. If a time-dependent status becomes hot, move toward derived fields or scheduled recalculation.
6. Paginate deliberately
For long admin lists, prefer a deliberate pagination strategy over unbounded reactive collections.
If the list needs to stay reactive while paginating, follow the same principles described in Convex's reactive pagination guidance instead of building bespoke client-side cache logic.
Recommended Guardrails
Once the wrapper modules exist, add lightweight enforcement:
Lint / import rules
- forbid direct
convex/reactimports fromapps/admin/src/** - require client Convex hooks to come from
@/core/data/client - require server fetch helpers to come from
@/core/data/server
Script checks
Add simple scripts, modeled on Ordna, to catch:
- forbidden server fetching in the wrong route files
- forbidden direct Convex hook imports in feature code
- optionally, forbidden raw
fetchQueryusage outside the server data layer - optionally, repeated direct session/access queries where a shared provider hook should exist
Adoption Plan For This Repo
Do this in order.
Phase 1. Lock the standard
Done by this doc.
Phase 2. Introduce the wrapper layer
Add:
apps/admin/src/core/data/client/index.tsapps/admin/src/core/data/server/index.ts
Do not change behavior yet beyond centralization.
Phase 3. Add cached client queries
Install convex-helpers in apps/admin and add ConvexQueryCacheProvider.
Then migrate feature imports from raw convex/react to the wrapper layer.
In the same phase, add useStableQuery so the app can intentionally avoid loading flicker during filter/arg transitions.
Phase 4. Centralize shared session/access state
Create a provider or hook for viewer session access and remove duplicated calls to api.viewer.session.getViewerSessionAccess.
Phase 5. Audit high-subscription pages
Prioritize:
- activity page
- lead detail
- billing operations
For each page, classify every read as:
- shared provider data
- must be live
- can be point-in-time
- can be batched with a sibling query
- should preserve previous data during arg changes
Phase 6. Audit backend hot paths
Prioritize:
apps/admin/convex/system.tsapps/admin/convex/billing.tsapps/admin/convex/routing.tsapps/admin/convex/activityLog.ts
The goal is to move repeated scan/filter patterns toward indexed or summary-oriented queries before table growth makes the dashboard expensive.
Default Rules To Follow Starting Now
Until the wrapper implementation lands, use these rules for all new work:
- Do not add new direct
convex/reactusage in feature code if a local wrapper can be introduced instead. - Do not add preloading to admin dashboard screens unless there is a very specific first-paint reason.
- Prefer one well-shaped query over many tiny sibling subscriptions when the data is always rendered together.
- Do not duplicate session/access queries across multiple sibling components.
- On the backend, treat
.collect()on operational tables as a deliberate choice that must stay bounded. - If a dashboard query scans broad tables, call that out explicitly in code review.
- If a component flashes to loading during normal filter/arg changes, treat that as a UX bug to solve, not normal behavior to accept.
Short Version
The target pattern is:
apps/admindashboards: cached clientuseQuery- no-flicker flows: cached
useStableQuerywhen preserving prior resolved data is the better UX - server route logic: wrapped
fetchQueryonly for auth/validation/gating - preloading: rare in admin, normal only where first paint truly matters
- shared data: provider/context hooks, not repeated raw queries
- backend hot paths: indexed and intentionally shaped, not scan-and-filter by default
That is the Ordna pattern worth copying here.