Admin Worker Data Fetching
This document records the decision for how the admin_worker Admin Console
(React Router 7 on Cloudflare Workers) fetches data on the client, why we chose
it, and the conventions every new feature must follow.
Background
The console historically hand-rolled client-side fetching with
useState + useEffect + fetch in each component (~115 call sites across
~51 files). That pattern repeatedly produced the same class of defects:
- Cross-route staleness. Index detail tabs render under a single route
(
indexes/:indexId/:tab). Navigating between two indexes is a param-only change, so components stay mounted and their fetched state survives the re-render. A component that fetched on mount (or keyed its effect wrong) kept showing the previous index's data. PR #3356 patched one instance (IndexAliasBanner) with an identity-guard band-aid; the underlying class remained everywhere else. - No request cancellation. Overlapping fetches from rapid navigation could resolve out of order and clobber fresh data with stale responses.
- Duplicated plumbing. Every component re-implemented loading/error/refetch state, and components reading the same resource each fetched it independently (no dedup, no shared cache).
Decision: a hybrid of RR7 loaders + TanStack Query
We use two data-fetching mechanisms, each for the job it is best at:
| Concern | Mechanism | Why |
|---|---|---|
| Route-level initial load of a page's primary data | RR7 loader / clientLoader | Runs before render, integrates with RR7 navigation, SSR-friendly, no loading flash for the first paint. |
| Shared, cross-component, polled, refetched, or mutated data | TanStack Query (@tanstack/react-query) | Caching + dedup keyed by resource identity, automatic request cancellation, useMutation + invalidateQueries for write-then-refresh, one definition reused across components. |
Rationale
- The staleness class is fixed by construction, not by guards. A TanStack
Query read is keyed on the resource's identifiers (e.g.
["index-settings", systemAccountId, indexName]). When a param-only navigation changes those identifiers, the hook switches to a different cache entry. Because we deliberately do not useplaceholderData: keepPreviousDataon identity-keyed reads, the new key returnsundefined(loading) rather than the prior index's data. There is no way to render index A's aliases on index B's page. - One resource, one cache entry.
IndexAliasBannerandAliasesTabboth read index settings. As query hooks they dedupe to a single in-flight request and a single cached result. - Cancellation is free. TanStack Query passes an
AbortSignalto the query function, which our HTTP client forwards tofetch. - Mutations stop threading
onRefresh. A write callsuseMutation; itsonSuccessinvalidates the resource's query key, and every reader re-renders with fresh data. The oldRefreshButton/handleRefreshprop-drilling is removed. - Loaders still own first paint. Where a route's primary data is needed before render, the RR7 loader remains the right tool — it avoids a loading flash and keeps navigation blocking semantics. We layer Query on top for the interactive/shared/mutating slices of that page.
When to use which
- Use an RR7 loader when the data is the route's primary payload, needed for first paint, and not shared with sibling components or mutated in place.
- Use a TanStack Query hook when the data is read by more than one component, polled/refetched, written then re-read, or keyed on identifiers that change without remounting (index detail tabs are the canonical case).
- Do not reintroduce raw
useState+useEffect+fetchfor server data. If neither mechanism fits, document the exception at the call site.
Conventions
1. Provider and defaults
A single QueryClientProvider wraps the app at the root
(app/lib/query-provider.tsx, mounted in app/root.tsx). Client defaults
(app/lib/query-client.ts):
staleTime: 30_000, // 30s — avoid refetch storms on quick remounts
retry: 1, // one retry, then surface the error (fail fast)
refetchOnWindowFocus: false // the console is not a live dashboard
SSR/hydration: getQueryClient() returns a fresh QueryClient per request
on the server (typeof window === "undefined") and a module singleton in the
browser, so server renders never share cache across requests. Client-side
queries stay pending during SSR (effects do not run on the server) and resolve
on hydration.
2. Resource hooks live in app/lib/queries/
Each resource gets one module exporting:
- a query-key factory whose keys include every resource identifier (this inclusion is what eliminates the staleness class — never omit one);
- a read hook (e.g.
useIndexSettings(systemAccountId, indexName)) that isenabledonly when its identifiers are present; - mutation hooks built on a shared helper that invalidates the resource's query key on success.
Components must reuse these hooks. Do not redefine queryKey/queryFn
inline per component — that is exactly the duplication this layer removes.
See app/lib/queries/index-settings.ts for the reference implementation.
3. HTTP goes through the typed client
app/lib/api-client.ts is the client-side counterpart to the server's
GatewayClient. Use apiGet<T>(path, { signal, fallbackMessage }) and
apiMutate(method, path, { body, signal, fallbackMessage }); both throw
ApiError (carrying .status) on non-OK responses. Pass the signal that
TanStack Query hands the query function so reads cancel on navigation.
4. No keepPreviousData on identity-keyed reads
placeholderData: keepPreviousData re-introduces the staleness bug for reads
keyed on a resource's identity: it would paint the previous index's data during
the switch. It is banned for those reads. (It remains acceptable for
genuinely paginated lists keyed on a cursor, where showing the prior page
during fetch is the desired UX — but that is a different key shape.)
Migration status
This is an incremental migration. The foundation (provider, client, key factories, the index-settings resource) and the index alias family are migrated:
app/lib/api-client.ts,app/lib/query-client.ts,app/lib/query-provider.tsx,app/lib/queries/index-settings.tsIndexAliasBanner(temporary #3356 identity guard removed — the query key now makes it unnecessary)AliasesTab+ReadAliasesSection/WriteAliasesSection/AnalyticsAliasSection(reads viauseIndexSettings, writes via mutation hooks;onRefreshplumbing removed)
Remaining useState/useEffect/fetch call sites in other tabs, routes, and
the analytics/testing modules are tracked as follow-up work and should be
migrated to this pattern as they are touched. New code must follow the
conventions above from the outset.