Skip to main content

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:

ConcernMechanismWhy
Route-level initial load of a page's primary dataRR7 loader / clientLoaderRuns before render, integrates with RR7 navigation, SSR-friendly, no loading flash for the first paint.
Shared, cross-component, polled, refetched, or mutated dataTanStack 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 use placeholderData: keepPreviousData on identity-keyed reads, the new key returns undefined (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. IndexAliasBanner and AliasesTab both 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 AbortSignal to the query function, which our HTTP client forwards to fetch.
  • Mutations stop threading onRefresh. A write calls useMutation; its onSuccess invalidates the resource's query key, and every reader re-renders with fresh data. The old RefreshButton / handleRefresh prop-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 + fetch for 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 is enabled only 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.ts
  • IndexAliasBanner (temporary #3356 identity guard removed — the query key now makes it unnecessary)
  • AliasesTab + ReadAliasesSection / WriteAliasesSection / AnalyticsAliasSection (reads via useIndexSettings, writes via mutation hooks; onRefresh plumbing 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.