Skip to content

09 — TanStack Query Patterns

Migrated from root technical docs.

All feature state modules use TanStack Query for server state. This document defines the canonical patterns for query keys, query option factories, and mutations.


Every feature that has server state owns a query-keys.ts file at its feature root:

packages/feature-publications/src/query-keys.ts
apps/dashboard/src/features/works/query-keys.ts
apps/dashboard/src/features/projects/query-keys.ts
packages/feature-people/src/query-keys.ts
packages/feature-activity/src/query-keys.ts

Cross-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.


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 queries pass the entire search/filter object as the second element. Do not spread individual fields as positional arguments.

// ✅ correct — object param
list: (search: PublicationSearch) => ["publications", search] as const,
// ❌ wrong — positional params
list: (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 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.

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,
};

All queryOptions() calls live in queries.ts. Each factory is a named const that takes typed inputs and returns a queryOptions(...) object.

// ✅ canonical form
export 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 a dynamic import of the server function module. Never import server functions at the top of a file.
  • Retry policy: use getResponseStatus + isRetryableStatus from @legaciti/platform-query; retry at most twice.
  • Stale time: default 0 for detail queries (always-fresh). Set explicit staleTime for list queries as appropriate.
  • Placeholder data: placeholderData: (previous) => previous keeps the previous page visible during pagination or filter changes.

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.invalidateQueries after a successful mutation.
  • Cross-tab sync: emit invalidation events with emitCacheInvalidations from @legaciti/platform-events so other open tabs update automatically.
  • Optimistic updates: use onMutate / onError rollback only when the UI would benefit from immediate feedback. For most list mutations, post-success invalidation is sufficient.

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.ts
export const peopleListQueryKey = (search: Record<string, unknown>) =>
["people", search] as const;

This avoids creating a runtime dependency cycle between feature packages.


FileOwns
query-keys.tsKey factories only; no imports from @tanstack/react-query
queries.tsqueryOptions() factories plus cache invalidation helpers
mutations.tsuseMutation hooks with post-success invalidation