# Consumer data caching (TanStack Query) The consumer app (`app/(consumer)/**`, guest and host pages alike) fetches and caches server data through [TanStack Query](https://tanstack.com/query) v5, not ad-hoc `useState` + `useEffect`. This replaced a per-page pattern of manual loading/error state and full-list reloads after every mutation. Read this before adding a new fetch, list, or mutation under `(consumer)`. **Full-stack caching (guest ISR vs logged-in dynamic, Next Data Cache, Redis):** see [`caching-strategy.md`](./caching-strategy.md) — read that first when changing SSR, `revalidate`, or public discovery endpoints. ## Core pieces - **`lib/queryClient.ts`** — `makeQueryClient()` builds the `QueryClient` with the project defaults (`staleTime: 30s`, `gcTime: 5min`, `refetchOnWindowFocus/Reconnect: true`, `retry: 1`). `getQueryClient()` is the browser-side singleton accessor: on the server it always returns a fresh client (no cross-request state leak); in the browser it memoizes one instance in a module-level variable so client-side navigations share a single cache instead of rebuilding it per page. - **`app/providers.tsx`** — wraps the app in `QueryClientProvider`, seeded from `getQueryClient()` via `useState(() => getQueryClient())` (so the provider itself doesn't recreate the client on re-render). - **`queries/consumerKeys.ts`** — the single query-key factory. Every query key used by consumer code should come from here, not be hand-written inline — it's what makes cache patches and invalidations from one file reliably reach queries defined in another. Read the file's own comment about `following()` being a _prefix_ of `followingCount()`/`isFollowing()` before adding a new key in that shape; pass `{ exact: true }` when you mean only the list itself. - **`queries/unwrapService.ts`** — every service function in this codebase returns a `ServiceResult` (`{ ok, data }` or `{ ok: false, error }`), but TanStack Query wants a `queryFn` that either resolves or throws. `unwrapService(await SOME_SERVICE_CALL(...))` bridges the two: pass `{ errorMode: 'parent' }` to the service call so its own error handling doesn't swallow the failure before `unwrapService` gets to throw it. ## Query/mutation hook layer (`queries/consumer/*.ts`) One file per data domain (`useWalletQuery.ts`, `useBookingQueries.ts`, `useFollowingQueries.ts`, …), each exporting plain hooks — no classes, no shared base hook. Conventions to follow: - Accept an `enabled = true` parameter on read hooks so callers can gate a query behind auth state or a tab being active, without duplicating the query definition. - Call `unwrapService(await SOME_SERVICE(..., { errorMode: 'parent' }))` inside `queryFn` for anything that returns a `ServiceResult`; pass through `signal` when the underlying service call accepts one, so navigating away cancels the in-flight request. - Mutations live next to the queries they affect (e.g. `useCancelBookingMutation.ts`, `useHostedEventMutations.ts`), not inside page components. A mutation's `onSuccess` should patch the cache directly with `queryClient.setQueryData(key, updater)` for the specific list/record it changed, rather than a blanket `invalidateQueries` + refetch — this keeps the UI from flashing a full reload and preserves scroll position. Only fall back to `invalidateQueries` for data the mutation doesn't have the fresh shape of in hand (e.g. cancelling a booking invalidates `wallet()` because the refund amount isn't known client-side). - Because mutation hooks patch the shared cache directly, list-item components (`BookingListItem`, `HostedEventListItem`, …) don't need `onChanged`/`onDeleted` callback props threaded up to the page — each row calls its own mutation hook and the shared list query updates itself. ## Paginated lists: always `useInfiniteQuery`, never manual page-accumulation Every paginated list (`following`/`followers`, discovery events, chat messages) uses `useInfiniteQuery`, even the ones with an unusual pagination shape (chat — see below). **Do not** build pagination by hand on top of a plain `useQuery` (fetch page 1 in `queryFn`, then `setQueryData` to prepend further pages on "load more"). That shape is a live bug: `useQuery`'s `queryFn` has no idea a second page was ever merged in, so any background refetch — window refocus, reconnect, or just the query going stale — calls `queryFn` again, which re-fetches _only_ page 1 and silently overwrites the whole accumulated list. `useInfiniteQuery` doesn't have this problem: a refetch replays every page currently in `data.pages`, each with the exact `pageParam` it was originally fetched with, so accumulated history survives a background refresh. **Chat messages** (`features/chat/useChatThread.ts`) are the one non-obvious case: the API always returns the _newest_ window first with a stable `before` cursor for going further back, so page 0 (fetched with `pageParam: undefined`) is the newest page, and pages fetched via `fetchNextPage()` afterwards are progressively _older_. Rendering therefore reverses the `pages` array before flattening (`[...pages].reverse()`, each page's own items already oldest→newest) to get a chronological thread. New messages — from `SEND_MESSAGE` or the chat socket — get appended into `pages[0]` (the newest page), never onto a flat array, via a shared `appendMessageToCache` helper. `fetchNextPage()` **swallows errors by default** — the underlying promise is `.catch(noop)`'d unless you pass `{ throwOnError: true }`. Every "load more" handler in this codebase that wraps `fetchNextPage()` in a try/catch (`ConsumerHomeDiscovery.tsx`, `useChatThread.ts`) passes `throwOnError: true`, otherwise the catch block is dead code and load-more failures fail silently. `following`/`followers` don't need this because they don't wrap the call in a try/catch — they read the error reactively off `query.error`/`query.isError` instead, which TanStack still sets correctly even when the promise itself is swallowed. ## Seeding the cache from SSR data `ConsumerHomeDiscovery.tsx` receives server-rendered `initialCategories` / `initialCities` / `initialHomeFeed` as props (from `app/(consumer)/page.tsx`, ISR `revalidate = 60` plus `next: { revalidate }` on `lib/seo/serverApi.ts` fetches — see [`caching-strategy.md`](./caching-strategy.md) §4). Those props seed the query cache's `initialData` / `placeholderData` instead of the client re-fetching on mount — but only for the _default/no-filter_ selection, since that's the only case the SSR fetch matches. Changing a filter creates a new query key with no `initialData` and fetches normally. If you add a new SSR-seeded query, gate its `initialData` the same way (`isDefaultSelection && ...`) or you'll seed stale data for a filter combination the server never actually fetched. ## Bottom-nav tab keep-alive (UI layer) TanStack Query caches **data** across tab switches; this layer caches **mounted page trees** for the four bottom-nav roots (`/`, `/my-events`, `/chats`, `/profile`). - **`components/consumer/ConsumerBottomNavKeepAlive.tsx`** — lazy-mounts each tab once, keeps inactive panels in the DOM (`hidden` + `inert`), and gives each tab its own `overflow-y-auto` scroller (`data-consumer-tab-panel="{tab}"`). Each panel also provides `ConsumerTabActivationContext`: visible panels report active; hidden panels report inactive; pages outside this keep-alive shell default to active. - **`lib/consumerTabKeepAlive.ts`** — tracks which tabs have been mounted so `app/(consumer)/loading.tsx` can skip the route skeleton on revisit. - **`app/(consumer)/layout.tsx`** — tab roots render inside the keep-alive shell; nested routes (e.g. `/profile/wallet`, `/category/...`) render in a stack layer above the hidden panels. Event soft-nav (`/e/...`) still uses the existing overlay pin logic. - **Scroll** — per-tab scroll lives on each panel scroller (`lib/consumerTabScroll.ts`); pull-to-refresh on home reads the active panel via `ConsumerTabPanelScrollContext`. Keep-alive preserves component state; it must not keep expensive background work active. Root-tab queries combine their existing eligibility condition with `useIsConsumerTabActive()`. The Chats root also pauses its page-level socket subscription, and Home disables pull-to-refresh listeners while hidden. The consumer-shell socket transport and global unread badge remain active by design. When a tab becomes visible again, its cached UI is immediate and stale enabled queries revalidate normally. Nested discovery URLs under Home (`/category/...`, `/city/...`) are **not** tab roots: they use the stack layer while the home panel stays mounted underneath. ## Session data: `AuthContext.user` vs. the `me()` query cache `AuthContext.user` (bootstrapped synchronously from `localStorage`, no network call) and `consumerKeys.me()` (the live `useMeQuery()` cache, `services/users.ts`'s `GET_ME`/`PATCH_ME`) are two copies of overlapping profile fields (name, avatar, bio, city, default address). Don't hand-sync them from a mutation's `onSuccess` — `AuthContext.tsx` subscribes to the query cache once, at the provider level, and mirrors `me()` into `user` automatically whenever that cache changes (`setQueryData`, invalidate-then-refetch, background refetch — anything). A mutation only needs to call `queryClient.setQueryData(consumerKeys.me(), result.data)` (or `invalidateQueries`); the profile fields on `useAuth().user` update on their own. This also means `logout()` clearing the whole cache (`getQueryClient().clear()`) can't leave a previous user's profile fields sitting in `AuthContext.user` after the redirect. ## Testing Any component or hook under test that calls `useQuery`/`useMutation`/ `useQueryClient` needs a `QueryClientProvider` in the test tree — there is no ambient one. The convention across this codebase's tests (`BookingActions.test.tsx`, `OrganizerFollowButton.test.tsx`, `useProfileAccount.test.ts`, …) is a small local helper: ```ts const renderWithQuery = (ui: React.ReactElement) => { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) return render({ui}) } ``` (`renderHook` needs the same client, passed as its `wrapper` option, when testing a hook directly instead of a component.) Turn `retry` off in the test client — otherwise a deliberately-failing mock service call retries before the query settles into its error state, and `waitFor` assertions on error UI become flaky/slow.