Skip to main content

Convex Data Fetching Standard

Use this document when adding or refactoring Convex reads in this monorepo.

It does two things:

  1. records the current audit of apps/admin
  2. 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 useQuery vs fetchQuery vs preloadQuery
  • 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 undefined and showing loading states unless that state is actually useful

This is the key distinction from many manual cache-invalidation systems.

With Convex:

  • useQuery is 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 useQuery behavior causes distracting loading flashes during arg changes
    • keep previous resolved data until the next result is ready when that UX is preferable
  • 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 fetchQuery and preloadQuery with 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.tsx
  • ordna/apps/app/src/core/data/client/index.ts
  • ordna/apps/app/src/core/data/server/convex.ts
  • ordna/apps/app/src/core/data/patterns/README.md
  • ordna/scripts/check-page-server-fetching.mjs
  • ordna/scripts/check-app-server-fetching.mjs

Current State In apps/admin

The admin app does not yet implement that layer.

Current observations:

  • no ConvexQueryCacheProvider is present
  • no convex-helpers/react/cache/* usage is present
  • no fetchQuery, preloadQuery, or usePreloadedQuery usage is present
  • there is no src/core/data/client or src/core/data/server wrapper layer
  • feature pages import directly from convex/react

Examples:

  • apps/admin/src/core/providers/convex-client-provider.tsx creates ConvexReactClient and wraps auth, but does not add a query cache provider
  • apps/admin/src/features/dashboard/components/dashboard-page.tsx uses raw useQuery
  • apps/admin/src/features/activity/components/activity-feed-page.tsx creates many independent subscriptions in one screen
  • apps/admin/src/features/leads/components/lead-detail-page.tsx loads one page through many separate live queries

Audit Signals

Codebase-wide signals from the current repo:

  • 17 apps/admin/src files import directly from convex/react
  • 47 useQuery(...) calls exist in apps/admin/src
  • 51 .collect() calls exist in apps/admin/convex
  • 59 .filter(...) calls exist in apps/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:

  • ThemeProvider
  • ConvexAuthNextjsProvider
  • TooltipProvider

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:

  • fetchQuery inside page files where preloadQuery is 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 undefined flashes during argument changes inside an already-mounted screen

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
  • useMutation
  • useAction
  • useConvex
  • usePreloadedQuery

B. Add a cache provider

When we are ready to implement the Ordna pattern fully, add:

  • convex-helpers
  • ConvexQueryCacheProvider

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:

  • fetchQuery
  • preloadQuery

and own token/auth concerns for server-side reads.

Recommended helpers:

  • fetchQueryWithAuth
  • fetchQueryWithOptionalAuth
  • preloadQueryWithAuth
  • preloadQueryWithOptionalAuth

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.

Once the wrapper modules exist, add lightweight enforcement:

Lint / import rules

  • forbid direct convex/react imports from apps/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 fetchQuery usage 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.ts
  • apps/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.ts
  • apps/admin/convex/billing.ts
  • apps/admin/convex/routing.ts
  • apps/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:

  1. Do not add new direct convex/react usage in feature code if a local wrapper can be introduced instead.
  2. Do not add preloading to admin dashboard screens unless there is a very specific first-paint reason.
  3. Prefer one well-shaped query over many tiny sibling subscriptions when the data is always rendered together.
  4. Do not duplicate session/access queries across multiple sibling components.
  5. On the backend, treat .collect() on operational tables as a deliberate choice that must stay bounded.
  6. If a dashboard query scans broad tables, call that out explicitly in code review.
  7. 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/admin dashboards: cached client useQuery
  • no-flicker flows: cached useStableQuery when preserving prior resolved data is the better UX
  • server route logic: wrapped fetchQuery only 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.