Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
10 KiB
Consumer data caching (TanStack Query)
The consumer app (app/(consumer)/**, guest and host pages alike) fetches
and caches server data through TanStack 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 — read that first when
changing SSR, revalidate, or public discovery endpoints.
Core pieces
lib/queryClient.ts—makeQueryClient()builds theQueryClientwith 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 inQueryClientProvider, seeded fromgetQueryClient()viauseState(() => 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 aboutfollowing()being a prefix offollowingCount()/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 aServiceResult<T>({ ok, data }or{ ok: false, error }), but TanStack Query wants aqueryFnthat 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 beforeunwrapServicegets 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 = trueparameter 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' }))insidequeryFnfor anything that returns aServiceResult; pass throughsignalwhen 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'sonSuccessshould patch the cache directly withqueryClient.setQueryData(key, updater)for the specific list/record it changed, rather than a blanketinvalidateQueries+ refetch — this keeps the UI from flashing a full reload and preserves scroll position. Only fall back toinvalidateQueriesfor data the mutation doesn't have the fresh shape of in hand (e.g. cancelling a booking invalidateswallet()because the refund amount isn't known client-side). - Because mutation hooks patch the shared cache directly, list-item
components (
BookingListItem,HostedEventListItem, …) don't needonChanged/onDeletedcallback 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 §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 ownoverflow-y-autoscroller (data-consumer-tab-panel="{tab}"). Each panel also providesConsumerTabActivationContext: 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 soapp/(consumer)/loading.tsxcan 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 viaConsumerTabPanelScrollContext.
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:
const renderWithQuery = (ui: React.ReactElement) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}
(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.