{"filename":"tanstack-query.mdc","content":"---\ndescription: \"TanStack Query v5 (React Query) patterns including queryOptions helper, query key factories, mutations, optimistic updates, infinite queries, Suspense mode, and prefetching\"\nglobs: [\"src/**/*.tsx\", \"src/**/*.ts\", \"src/queries/**/*\"]\nalwaysApply: false\n---\nYou are an expert in TanStack Query v5 (React Query), TypeScript, and async state management.\n\n## Core Principles\n- TanStack Query manages server state — NOT a general client state manager\n- Every query needs a stable, serializable query key that uniquely describes the data\n- Mutations handle writes; queries handle reads — never blur this boundary\n- Use `queryOptions()` helper (v5) for reusable, co-located query definitions\n- v5 breaking change: `useQuery` only accepts options object form — no positional args\n\n## QueryClient Setup\n```tsx\nconst queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      staleTime: 1000 * 60,\n      retry: (count, error: any) => error?.status !== 404 && count < 2,\n    },\n  },\n})\n```\n\n## Query Key Factory Pattern\n```ts\nexport const postKeys = {\n  all: ['posts'] as const,\n  lists: () => [...postKeys.all, 'list'] as const,\n  list: (filters?: PostFilters) => [...postKeys.lists(), filters] as const,\n  details: () => [...postKeys.all, 'detail'] as const,\n  detail: (id: string) => [...postKeys.details(), id] as const,\n}\n```\n\n## queryOptions Helper (v5)\n```ts\nexport const postQueryOptions = (id: string) =>\n  queryOptions({\n    queryKey: postKeys.detail(id),\n    queryFn: () => fetchPost(id),\n    staleTime: 1000 * 60 * 5,\n  })\n\n// In component\nconst { data } = useQuery(postQueryOptions(postId))\n\n// In router loader\nloader: ({ params, context: { queryClient } }) =>\n  queryClient.ensureQueryData(postQueryOptions(params.postId))\n```\n\n## Mutations\n```tsx\nconst { mutate, isPending } = useMutation({\n  mutationFn: (input: CreatePostInput) => createPost(input),\n  onSuccess: () => {\n    queryClient.invalidateQueries({ queryKey: postKeys.lists() })\n  },\n  onError: (error) => toast.error(error.message),\n})\n```\n\n## Optimistic Updates\n```tsx\nconst mutation = useMutation({\n  mutationFn: updatePost,\n  onMutate: async (updated) => {\n    await queryClient.cancelQueries({ queryKey: postKeys.detail(updated.id) })\n    const previous = queryClient.getQueryData(postKeys.detail(updated.id))\n    queryClient.setQueryData(postKeys.detail(updated.id), updated)\n    return { previous }\n  },\n  onError: (_, updated, ctx) => {\n    queryClient.setQueryData(postKeys.detail(updated.id), ctx?.previous)\n  },\n  onSettled: (_, __, updated) => {\n    queryClient.invalidateQueries({ queryKey: postKeys.detail(updated.id) })\n  },\n})\n```\n\n## Infinite Queries\n```tsx\nconst { data, fetchNextPage, hasNextPage } = useInfiniteQuery({\n  queryKey: postKeys.lists(),\n  queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),\n  initialPageParam: undefined as string | undefined,\n  getNextPageParam: (lastPage) => lastPage.nextCursor,\n})\nconst allPosts = data?.pages.flatMap((p) => p.items) ?? []\n```\n\n## Suspense Mode (v5)\n```tsx\n// useSuspenseQuery — no isLoading needed, Suspense handles it\nconst { data } = useSuspenseQuery(postQueryOptions(postId))\n// Wrap with <Suspense fallback={<Skeleton />}> + <ErrorBoundary>\n```\n\n## Key Rules\n- Always define `queryOptions` outside components — never inline in `useQuery()`\n- Never use `useEffect` to fetch data — use loaders or `useQuery`\n- Use `placeholderData: keepPreviousData` for pagination to avoid layout shifts\n- Instantiate `QueryClient` once at app root — never inside a component\n"}