09 — TanStack Query Patterns
Section titled “09 — TanStack Query Patterns”All feature state modules use TanStack Query for server state. This document defines the canonical patterns for query keys, query option factories, and mutations.
Query key files
Section titled “Query key files”Every feature that has server state owns a query-keys.ts file at its feature root:
packages/feature-publications/src/query-keys.tsapps/dashboard/src/features/works/query-keys.tsapps/dashboard/src/features/projects/query-keys.tspackages/feature-people/src/query-keys.tspackages/feature-activity/src/query-keys.tsCross-cutting shell state (user session, workspace, dashboard aggregate stats) is keyed via
shellQueryKeys from @legaciti/platform-query. Feature files import this object — they do not
define their own keys for shell-owned data.
Query key shape rules
Section titled “Query key shape rules”Root namespace key
Section titled “Root namespace key”Every feature defines an all constant (a single-element tuple) that serves as the namespace root:
export const publicationQueryKeys = { all: ["publications"] as const, ...};queryClient.invalidateQueries({ queryKey: publicationQueryKeys.all }) invalidates every query
starting with "publications".
List key — typed object param
Section titled “List key — typed object param”List queries pass the entire search/filter object as the second element. Do not spread individual fields as positional arguments.
// ✅ correct — object paramlist: (search: PublicationSearch) => ["publications", search] as const,
// ❌ wrong — positional paramslist: (cursor, page, perPage, q, sort, dir, visibility, ...) => [...] as const,Rationale: Positional args are order-sensitive and break silently when new filter dimensions are added. The object form is self-documenting and matches TanStack Query’s own recommendations.
The typed SearchType (e.g. PublicationSearch, WorkSearch, ProjectSearch) must be imported
from the feature’s ./types file and used as the param type — not Record<string, unknown>.
Detail key
Section titled “Detail key”Detail queries use a separate, namespaced key:
detail: (id: string) => ["publication-detail", id] as const,Note: Detail keys intentionally use their own root namespace ("publication-detail", not
"publications") so that list and detail caches are invalidated independently. Avoid using
publicationQueryKeys.all to invalidate detail entries — use publicationQueryKeys.detail(doi)
directly.
Other sub-keys
Section titled “Other sub-keys”For feature-specific sub-keys (e.g. queueStatus, person, categories), extend the object with
named constants or factory functions as appropriate:
export const activityQueryKeys = { all: ["activity"] as const, queueStatus: ["activity", "queue-status"] as const, list: (params: Record<string, unknown>) => ["activity", params] as const,};Query option factories (queries.ts)
Section titled “Query option factories (queries.ts)”All queryOptions() calls live in queries.ts. Each factory is a named const that takes typed
inputs and returns a queryOptions(...) object.
// ✅ canonical formexport const publicationsQueryOptions = (search: PublicationSearch) => queryOptions({ queryKey: publicationQueryKeys.list(search), queryFn: async () => { ... }, placeholderData: (previous) => previous, retry: (failureCount, error: unknown) => { const status = getResponseStatus(error); return failureCount < 2 && isRetryableStatus(status); }, });Rules:
- Query function: always a
queryFn: async () => { ... }with adynamic importof the server function module. Never import server functions at the top of a file. - Retry policy: use
getResponseStatus+isRetryableStatusfrom@legaciti/platform-query; retry at most twice. - Stale time: default
0for detail queries (always-fresh). Set explicitstaleTimefor list queries as appropriate. - Placeholder data:
placeholderData: (previous) => previouskeeps the previous page visible during pagination or filter changes.
Mutations (mutations.ts)
Section titled “Mutations (mutations.ts)”All mutation hooks live in mutations.ts. Each hook is a named useMutation export.
export function useUpdatePublicationVisibility() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ doi, visible }: VisibilityMutation) => { const { setPublicationVisibility } = await import("@host/server/functions/publications"); return call(() => setPublicationVisibility({ data: { id: doi, visible } }), ); }, onSuccess: (_data, { doi }) => { queryClient.invalidateQueries({ queryKey: publicationQueryKeys.all }); queryClient.invalidateQueries({ queryKey: publicationQueryKeys.detail(doi), }); emitCacheInvalidations([ publicationQueryKeys.all, publicationQueryKeys.detail(doi), ]); }, });}Rules:
- Invalidation: always invalidate via
queryClient.invalidateQueriesafter a successful mutation. - Cross-tab sync: emit invalidation events with
emitCacheInvalidationsfrom@legaciti/platform-eventsso other open tabs update automatically. - Optimistic updates: use
onMutate/onErrorrollback only when the UI would benefit from immediate feedback. For most list mutations, post-success invalidation is sufficient.
Cross-feature key aliasing
Section titled “Cross-feature key aliasing”When a feature package needs to read from another feature’s cache (e.g. one feature renders
a people picker owned by another feature), it must not import the foreign feature’s query-keys.ts. Instead, duplicate
the key factory locally with a comment:
// Must stay aligned with peopleQueryKeys.list in packages/feature-people/src/query-keys.tsexport const peopleListQueryKey = (search: Record<string, unknown>) => ["people", search] as const;This avoids creating a runtime dependency cycle between feature packages.
File responsibility summary
Section titled “File responsibility summary”| File | Owns |
|---|---|
query-keys.ts | Key factories only; no imports from @tanstack/react-query |
queries.ts | queryOptions() factories plus cache invalidation helpers |
mutations.ts | useMutation hooks with post-success invalidation |