# Admin dashboard data caching (TanStack Query) The admin dashboard (`app/(dashboard)/**`) is being migrated to TanStack Query v5, the same library already used by the consumer app (see `docs/consumer-caching.md`). Read that doc first for the shared basics (`lib/queryClient.ts`, `QueryClientProvider` in `app/providers.tsx`, `unwrapService`). This doc only covers where the admin side intentionally differs. ## `queries/admin/adminKeys.ts` is the admin key factory Parallel to `queries/consumerKeys.ts`, scoped under `['admin', ...]` instead of `['consumer', ...]`. One hook file per data domain lives in `queries/admin/*.ts`, same shape as `queries/consumer/*.ts`. ## `PaginatedList` owns one `useQuery` internally -- most admin pages need no changes 17 of the 20 admin sections render data through `components/PaginatedList.tsx`, passing only `url`/`itemsKey`/`columns`/`urlParams`. That component now runs a single `useQuery` internally, keyed on `adminKeys.list(url, resolvedParams)` where `resolvedParams` is the exact `{page, pageSize, sort, filters, ...}` object sent to the server (from `buildListParams`). Callers don't need their own query hook for the list itself -- just keep passing `url`/`columns` as before. `refresh()` on the `PaginatedListHandle` ref now does `queryClient.invalidateQueries({ queryKey: adminKeys.listByUrl(url) })` instead of a local re-render counter. The imperative method remains available for non-row workflows, while row actions use `useAdminMutation` directly as described below. **Why `useQuery`, not `useInfiniteQuery`:** unlike every consumer list (`consumer-caching.md`'s "always infinite query" rule), `PaginatedList` is a page-jump table -- `page`, `pageSize`, `totalItemsCount`, not an accumulating feed. `useInfiniteQuery` doesn't fit that shape. `placeholderData: keepPreviousData` is used instead, so changing page/sort/filter shows the previous page's rows (not a blank table) while the new page loads. **Why the fetch itself doesn't need a `services/*`/`ServiceResult` wrapper:** `components/paginated-list/paginatedListApi.ts`'s `fetchPaginatedList` already calls `axiosInstance` directly and either resolves `{ items, pagination }` or throws -- exactly what a bare `queryFn` wants. Only genuine cancellation (`CanceledError`/`AbortError`, from `useQuery`'s own signal) is rethrown as-is so TanStack's built-in cancellation handling still applies; every other error is normalized through `handleServiceError(error, { errorMode: 'silent' })` before being thrown, so `query.error.message` still gives the same localized text the old `loadError` state used to. ## `hooks/useAdminMutation.ts` owns admin row mutations and list invalidation The identity-verifications, reviews, settlements, user-reports, and withdrawal-requests sections use one mutation shape. The hook preserves the existing `runAction(id, action, successMessage)` call-site and exposes a per-row `pendingId`, while its successful mutation callback invalidates `adminKeys.listByUrl(url)`. Pages therefore do not need to thread a `PaginatedListHandle` ref through action callbacks just to refresh the list. `runAction` resolves to `true` on success and `false` after a handled failure. Modal actions use that result to close/reset the modal only after the server accepts the mutation. Axios/service errors are normalized in the hook and shown once through the shared toast copy. ## Raw-`axiosInstance` mutations don't need a service-layer rewrite first Most non-CRUD admin actions (approve/reject/hide/restore/etc.) call `axiosInstance` directly with no `services/*.ts` wrapper. Axios already throws on non-2xx, which is exactly what a bare `mutationFn` wants -- there's no need to introduce a `ServiceResult` service function before writing the mutation hook. Reserve `unwrapService(await X(..., { errorMode: 'parent' }))` for the sections that already have a real service (tags, blog-articles, event-categories, admin user detail/edit, wallet credit, geography).