Why TanStack Query Needs 'Never useEffect for Data' Rules
TanStack Query (formerly React Query) replaces the useEffect + useState + isLoading + error pattern with a single hook that handles: caching, deduplication, background refetching, stale data management, retry logic, and pagination. AI assistants generate the manual pattern because it dominates React training data — every tutorial before 2022 teaches useEffect + fetch. The result is code that reinvents what TanStack Query provides for free.
The manual pattern has well-known bugs: race conditions (fast navigation triggers two fetches, the stale one wins), no caching (re-navigate to a page, wait for the spinner again), no deduplication (three components mount with the same query, three requests fire), and loading state that blocks the entire component tree. TanStack Query solves all of these with zero configuration.
These rules target TanStack Query v5 with React. The API is nearly identical for Vue, Solid, and Angular adapters.
Rule 1: useQuery for All Data Fetching
The rule: 'Use useQuery for all server data fetching: const { data, isLoading, error } = useQuery({ queryKey: ["users", id], queryFn: () => api.getUser(id) }). Never use useEffect + fetch + useState for data that comes from an API. useQuery provides: automatic caching, background refetch, request deduplication, retry on failure, and stale-while-revalidate behavior — all without configuration.'
For conditional fetching: 'Use enabled option to conditionally fetch: useQuery({ queryKey: ["user", id], queryFn: ..., enabled: !!id }). When enabled is false, the query doesn't run — no request, no loading state. When id becomes available, the query runs automatically. Never use useEffect with a dependency array to trigger fetches — enabled handles it.'
AI generates: const [data, setData] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { fetch(...).then(setData).finally(() => setLoading(false)) }, [id]). That's 6 lines (with bugs) that useQuery replaces with 1 line (bug-free).
- useQuery for all API reads — never useEffect + fetch + useState
- Automatic: caching, deduplication, retry, background refetch, stale-while-revalidate
- enabled option for conditional fetching — not useEffect dependency arrays
- useQuery returns: data, isLoading, error, isStale, isFetching — all managed
- 6 buggy lines → 1 line with useQuery — race conditions impossible
useState + useEffect + fetch + setLoading + setError + setData = 6 buggy lines with race conditions. useQuery({ queryKey, queryFn }) = 1 line with caching, deduplication, retry, and zero race conditions.
Rule 2: Structured Query Keys
The rule: 'Use structured, hierarchical query keys: ["users"] for the list, ["users", id] for a specific user, ["users", id, "posts"] for a user's posts. Query keys determine caching — same key = same cache entry. Use query key factories for consistency: const userKeys = { all: ["users"] as const, detail: (id: string) => ["users", id] as const, posts: (id: string) => ["users", id, "posts"] as const }.'
For invalidation: 'Invalidate by prefix: queryClient.invalidateQueries({ queryKey: ["users"] }) invalidates ALL user-related queries (list, detail, posts). Invalidate specifically: queryClient.invalidateQueries({ queryKey: ["users", id] }) invalidates only that user. The hierarchical structure enables both broad and targeted invalidation.'
AI generates random string keys: useQuery({ queryKey: ["getUsers"] }), useQuery({ queryKey: ["fetchUserById"] }). These don't relate to each other — invalidating one doesn't affect the other. Structured keys create a cache hierarchy that makes invalidation intuitive.
Rule 3: staleTime and Cache Configuration
The rule: 'Configure staleTime based on data freshness requirements. Default (0): data is immediately stale — refetched on window focus, component mount, and reconnect. staleTime: 60_000 (1 minute): data is fresh for 1 minute — no refetch during that window. staleTime: Infinity: data never goes stale — only refetched on manual invalidation. Set staleTime per query or globally in QueryClient defaults.'
For common settings: 'User profile: staleTime: 5 * 60_000 (5 minutes — doesn't change often). Product catalog: staleTime: 60_000 (1 minute — updates occasionally). Dashboard metrics: staleTime: 0 (always fresh — realtime data). Static content: staleTime: Infinity (never changes — manually invalidate on update).'
AI leaves staleTime at 0 (default) — every component mount refetches, every window focus refetches. For data that changes rarely, this wastes bandwidth and shows unnecessary loading spinners. Set staleTime based on how often the data actually changes.
- staleTime: 0 = refetch on every mount/focus (default — too aggressive for most data)
- staleTime: 60_000 = fresh for 1 minute (good for lists and catalogs)
- staleTime: 5 * 60_000 = fresh for 5 minutes (good for profiles and settings)
- staleTime: Infinity = only manual invalidation (good for static content)
- gcTime (cacheTime): how long to keep unused data in memory — default 5 minutes
staleTime: 0 (default) refetches on every component mount and window focus. For a user profile that rarely changes, staleTime: 5 * 60_000 eliminates 99% of unnecessary refetches. Set it per data type.
Rule 4: useMutation with Invalidation
The rule: 'Use useMutation for all data modifications: const mutation = useMutation({ mutationFn: (data) => api.createUser(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["users"] }) } }). Call with mutation.mutate(data). Invalidation after success ensures the list refetches with the new data. Never manually update state after a mutation — let invalidation + refetch handle it (or use optimistic updates for instant UI).'
For optimistic updates: 'Update the cache before the mutation completes: onMutate: async (newTodo) => { await queryClient.cancelQueries({ queryKey: ["todos"] }); const previous = queryClient.getQueryData(["todos"]); queryClient.setQueryData(["todos"], (old) => [...old, newTodo]); return { previous }; }, onError: (err, newTodo, context) => { queryClient.setQueryData(["todos"], context.previous); }. The UI updates instantly; if the mutation fails, it rolls back.'
AI generates: fetch(url, { method: "POST" }).then(() => setData([...data, newItem])). This has no error handling, no rollback, no cache invalidation for other components showing the same data. useMutation handles all of this — including cache updates that propagate to every component.
Rule 5: Prefetching, Infinite Queries, and Suspense
The rule: 'Prefetch on hover/focus for instant navigation: queryClient.prefetchQuery({ queryKey: ["users", id], queryFn: () => api.getUser(id) }). Trigger on link hover: onMouseEnter={() => prefetch(id)}. The data is in cache before the user clicks — no loading spinner on the next page.'
For infinite queries: 'Use useInfiniteQuery for paginated/infinite scroll: const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey: ["posts"], queryFn: ({ pageParam = 0 }) => api.getPosts(pageParam), getNextPageParam: (lastPage) => lastPage.nextCursor }). Data is { pages: [...] } — flatten with data.pages.flatMap(page => page.items).'
For Suspense: 'Use useSuspenseQuery (v5) for Suspense integration: the query throws a Promise that Suspense catches. Wrap with <Suspense fallback={<Skeleton />}>. No more isLoading checks — the component only renders when data is available. Use ErrorBoundary for error handling alongside Suspense.'
- Prefetch on hover: queryClient.prefetchQuery — instant next-page navigation
- useInfiniteQuery for pagination — getNextPageParam, fetchNextPage, hasNextPage
- useSuspenseQuery (v5) for Suspense integration — no isLoading checks
- Suspense + ErrorBoundary: component renders only with data, errors caught above
- Never manual pagination state — useInfiniteQuery manages pages automatically
queryClient.prefetchQuery on link hover loads data before the user clicks. The next page renders instantly from cache. One line of code eliminates the loading spinner on navigation.
Complete TanStack Query Rules Template
Consolidated rules for TanStack Query projects.
- useQuery for all API reads — never useEffect + fetch + useState
- Structured query keys: ['entity', id, 'sub'] — key factories for consistency
- staleTime per data type: 0 for realtime, 60s for lists, 5m for profiles, Infinity for static
- useMutation + invalidateQueries — never manual state update after API calls
- Optimistic updates: onMutate sets data, onError rolls back — instant UI
- Prefetch on hover — useInfiniteQuery for pagination — useSuspenseQuery for Suspense
- QueryClientProvider at app root — devtools in development
- eslint-plugin-query for lint rules — testing with renderHook + QueryClient