In large-scale web applications, TanStack Query (formerly React Query) v5 acts as an asynchronous distributed server state cache. The architectural cornerstone of this caching layer is the Query Key — a serializable array of string literals, identifiers, and filter objects acting as the primary key for request deduplication, cache storage, and invalidation.
In immature codebases, query keys are often declared ad-hoc as loose string arrays across UI components:
// Fragile code: a single typo breaks cache invalidation at runtime!
useQuery({ queryKey: ['users', id, 'detail'] });
queryClient.invalidateQueries({ queryKey: ['user', id, 'details'] }); // Silent failure!To eliminate desynchronization and type drift, production architectures adopt the Query Key Factory Pattern paired with TanStack Query v5's core queryOptions() helper. This architectural approach delivers end-to-end type safety: from endpoint declarations to optimistic mutations backed by a strictly typed Rollback Context.
+-------------------------------------------------------------------------+
| END-TO-END TYPE-SAFE CACHE ARCHITECTURE IN V5 |
+-------------------------------------------------------------------------+
| [Query Key Factory: userKeys.detail(id)] |
| |--> Returns strictly typed tuple: readonly ['users', 'detail', string]
+-------------------------------------------------------------------------+
|
v
| [queryOptions({ queryKey, queryFn })] |
| |--> Binds query key type to queryFn arguments |
| |--> Automatically infers TQueryFnData, TError, TData |
| `-- Isomorphically shared: prefetchQuery (SSR) <-> useQuery (Client) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [Type-Safe Mutation: useMutation<User, Error, UpdatePayload, Context>] |
| |-- onMutate: (vars) => Promise<{ previousUser: User | undefined }> |
| |-- onError: (err, vars, context: Context | undefined) => Rollback |
| `-- onSettled: (data, err, vars, context) => Invalidate by Factory |
+-------------------------------------------------------------------------+Typing in TanStack Query v5 builds upon hierarchical projections and four-parameter generics:
['users']) or targeted queries (['users', 'list', { role: 'admin' }]).useQuery.Hierarchical Query Key Factory Structure:
userKeys.all -------------------> ['users']
|
|-- userKeys.lists() -----> ['users', 'list']
| `-- userKeys.list(filter) -> ['users', 'list', { status: 'active' }]
|
`-- userKeys.details() ---> ['users', 'detail']
`-- userKeys.detail(id) ----> ['users', 'detail', '42']
Invalidating ['users'] automatically clears all nested lists and detail views!MutationRollbackContext if the server returns a 500 error.getNextPageParam and strict validation across InfiniteData<PageResponse>.queryOptions declarations between server-side prefetchQuery in React Server Components and client-side useSuspenseQuery.Here is a zero-dependency, hierarchical query key factory for a User entity:
export interface UserListFilters {
readonly role?: 'admin' | 'member';
readonly search?: string;
readonly page?: number;
}
/**
* Hierarchical query key factory for user entities.
* Each method returns a strictly typed, immutable readonly tuple.
*/
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserListFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (userId: string) => [...userKeys.details(), userId] as const,
};
export type UserKeys = typeof userKeys;queryOptions and Data Selectors#In TanStack Query v5, the queryOptions helper automatically binds the queryFn return type to the options structure:
import { queryOptions } from '@tanstack/react-query';
import { userKeys, UserListFilters } from './userKeys';
export interface UserDto {
readonly id: string;
readonly fullName: string;
readonly email: string;
readonly createdAt: string;
readonly balanceCents: number;
}
async function fetchUserById(userId: string, signal?: AbortSignal): Promise<UserDto> {
const res = await fetch(`/api/users/${userId}`, { signal });
if (!res.ok) {
throw new Error(`[ApiError] Failed to load user: ${res.statusText}`);
}
return (await res.json()) as UserDto;
}
/**
* Type-safe user detail query options.
* TQueryFnData = UserDto
* TError = Error
*/
export const userDetailQueryOptions = (userId: string) =>
queryOptions({
queryKey: userKeys.detail(userId),
queryFn: ({ signal }) => fetchUserById(userId, signal),
staleTime: 60 * 1000, // 1 minute
});
/**
* Query configuration with data transformation via select:
* Selects only formatted display name and currency balance.
*/
export const userBalanceSummaryOptions = (userId: string) =>
queryOptions({
...userDetailQueryOptions(userId),
// TData is inferred from the select return type
select: (user): { name: string; balanceUsd: string } => ({
name: user.fullName,
balanceUsd: `$${(user.balanceCents / 100).toFixed(2)}`,
}),
});When designing optimistic updates, you must bind the return type of onMutate to the context parameter in onError:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { userKeys } from './userKeys';
import { UserDto } from './userQueries';
export interface UpdateBalanceVariables {
readonly userId: string;
readonly deltaCents: number;
}
export interface MutationRollbackContext {
readonly previousUser: UserDto | undefined;
}
export function useUpdateUserBalance() {
const queryClient = useQueryClient();
return useMutation<UserDto, Error, UpdateBalanceVariables, MutationRollbackContext>({
mutationFn: async ({ userId, deltaCents }) => {
const res = await fetch(`/api/users/${userId}/balance`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deltaCents }),
});
if (!res.ok) {
throw new Error(`[MutationError] Balance update failed: ${res.status}`);
}
return (await res.json()) as UserDto;
},
// 1. Optimistic update and context snapshot capture
onMutate: async ({ userId, deltaCents }) => {
const queryKey = userKeys.detail(userId);
// Cancel outgoing refetches to avoid overwriting optimistic updates
await queryClient.cancelQueries({ queryKey });
// Snapshot current cache state
const previousUser = queryClient.getQueryData<UserDto>(queryKey);
// Optimistically write new balance
if (previousUser) {
queryClient.setQueryData<UserDto>(queryKey, {
...previousUser,
balanceCents: previousUser.balanceCents + deltaCents,
});
}
// Return strictly typed rollback context
return { previousUser };
},
// 2. Rollback on network or server error
onError: (_err, { userId }, context) => {
if (context?.previousUser) {
queryClient.setQueryData(userKeys.detail(userId), context.previousUser);
}
},
// 3. Revalidate via key factory on completion
onSettled: (_data, _error, { userId }) => {
void queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });
},
});
}import { useInfiniteQuery } from '@tanstack/react-query';
import { userKeys } from './userKeys';
import { UserDto } from './userQueries';
export interface PaginatedUsersResponse {
readonly users: ReadonlyArray<UserDto>;
readonly nextCursor: string | null;
}
export function useUserInfiniteList(role?: 'admin' | 'member') {
return useInfiniteQuery<
PaginatedUsersResponse, // TQueryFnData
Error, // TError
PaginatedUsersResponse, // TData
readonly ['users', 'list', { role?: 'admin' | 'member' }], // TQueryKey
string | null // TPageParam
>({
queryKey: userKeys.list({ role }),
initialPageParam: null,
queryFn: async ({ pageParam, signal }) => {
const url = new URL('/api/users', window.location.origin);
if (role) url.searchParams.set('role', role);
if (pageParam) url.searchParams.set('cursor', pageParam);
const res = await fetch(url.toString(), { signal });
if (!res.ok) throw new Error('Failed to load user list');
return (await res.json()) as PaginatedUsersResponse;
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: () => null,
});
}The factory generates an immutable key tuple, preventing string literal desynchronization during invalidation.
Combines the key, network promise, and cache policy into an isomorphic options bundle, auto-inferring payload types.
onMutate cancels active queries, captures a cache snapshot into MutationRollbackContext, and writes local updates.
On failure, onError uses context to restore the cache. onSettled invalidates the target slice using the factory key.
import { QueryClient } from '@tanstack/react-query';
import { userKeys } from './userKeys';
export class EntityCacheManager {
constructor(private readonly queryClient: QueryClient) {}
/**
* Invalidates all cached queries related to users
*/
public async invalidateAllUsers(): Promise<void> {
await this.queryClient.invalidateQueries({
queryKey: userKeys.all,
});
}
/**
* Invalidates all user lists while leaving active detail views cached
*/
public async invalidateUserLists(): Promise<void> {
await this.queryClient.invalidateQueries({
queryKey: userKeys.lists(),
});
}
/**
* Forcibly purges a specific user's detail record from memory
*/
public removeUserDetail(userId: string): void {
this.queryClient.removeQueries({
queryKey: userKeys.detail(userId),
exact: true,
});
}
}If you omit as const when declaring query keys, TypeScript widens string literals like 'users' and 'detail' to string[]. This breaks fuzzy matching, type narrowing, and tuple autocompletion.
// ❌ WRONG: Widened to generic string[]
export const badKeys = {
all: ['users'], // string[]
};
// ✅ CORRECT: Preserved as immutable literal tuple
export const goodKeys = {
all: ['users'] as const, // readonly ['users']
};Omitting the 4th generic parameter in useMutation<TData, TError, TVariables, TContext> causes TypeScript to assign unknown to context. Accessing context.previousUser in onError will produce compiler error TS2339.
// ❌ WRONG: Context omitted, defaults to unknown
useMutation({
onMutate: () => ({ prev: 10 }),
onError: (err, vars, context) => {
// context.prev -> Error TS2339: Property 'prev' does not exist on type 'unknown'
}
});
// ✅ CORRECT: Explicit context generic declaration
useMutation<Data, Error, Vars, { prev: number }>({ ... });If your fetcher function ignores the signal argument, the browser cannot abort in-flight network requests when a component unmounts or navigation occurs.
// ❌ WRONG: Non-cancellable request
queryFn: () => fetch('/api/data').then(r => r.json());
// ✅ CORRECT: Forwarding the AbortSignal
queryFn: ({ signal }) => fetch('/api/data', { signal }).then(r => r.json());Mutating properties on objects retrieved from getQueryData breaks React Fiber referential equality and stops subscriber components from re-rendering.
// ❌ WRONG: Direct cache mutation
const user = queryClient.getQueryData(['users', id]);
user.balance = 500; // FORBIDDEN: Mutates cache reference directly!
// ✅ CORRECT: Functional immutable update
queryClient.setQueryData(['users', id], (old: User | undefined) =>
old ? { ...old, balance: 500 } : undefined
);Declare a hierarchical object returning immutable readonly tuples with as const.
Define query configurations pairing queryKey, typed queryFn, staleTime, and selectors.
Pass queryOptions to server-side prefetchQuery to eliminate client waterfall requests.
Configure useMutation with typed TContext for robust rollback during network errors.
Use key factory methods inside onSettled callbacks for deterministic cache invalidations.