Portuguese Edition Available: Leia a versão em português deste artigo: TanStack Query vs. useEffect: O Guia Definitivo de Data Fetching, Governança de Estado e Performance no React.
1. The Real Issue with useEffect for Fetching and the Shift to Remote State#
The useEffect hook was not designed for data fetching. It was built to synchronize components with external side effects (like subscriptions or manual DOM manipulations). When used for asynchronous network queries, it introduces structural architectural flaws:
- Silent Race Conditions: If a user navigates between pages or adjusts filters before a previous request finishes, multiple resolution callbacks trigger. The UI will render whichever request arrives last on the network, not necessarily the most recent one requested by the user.
- No Request Deduplication: If three components on the same screen require user data,
useEffecttriggers three separate, concurrent HTTP requests. - Resource and Memory Leaks: In React 17 and earlier, attempting to update state on an unmounted component yielded the warning:
Can't perform a React state update on an unmounted component. In React 18+, this log warning was removed, but the resource leak remains: the HTTP request continues executing in the browser background, wasting memory, bandwidth, and CPU cycles processing callbacks. - No Cache by Default: The interface flashes and shows a loading skeleton every time the component remounts, degrading the user experience.
Head-to-Head Comparison: useEffect vs. TanStack Query#
When evaluating the manual useEffect pattern against the declarative abstraction provided by TanStack Query, the architectural gap becomes immediately clear:
| Feature / Metric | Manual useEffect + useState | TanStack Query (React Query) |
|---|---|---|
| Boilerplate Overhead | High (requires 3 manual states: data, loading, error) | Minimal (single hook invocation returning structured payload) |
| In-Memory Caching | None (fresh network fetch on every component remount) | Native & automatic caching tuned via staleTime and gcTime |
| Request Deduplication | None (parallel mounts fire duplicate network calls) | Yes (shares and deduplicates identical promises by Query Key) |
| Race Condition Handling | Prone to bugs in rapid search/filters (needs AbortController) | Resolved by design (dynamic keys discard stale in-flight results) |
| Background Revalidation | Manual window event listeners or custom pollers | Automatic out-of-the-box (refetchOnWindowFocus, reconnect) |
| Mutation Management | Manual imperative side-effects and manual refetch orchestration | useMutation with declarative cache invalidation (invalidateQueries) |
| Optimistic Updates & Rollbacks | High complexity and brittle manual rollback logic | First-class lifecycle support (onMutate, onError, onSettled) |
| Pagination & Infinite Scroll | Requires custom array stitching and scroll coordination | Dedicated useInfiniteQuery hook with cursor management |
| Failure Retries & Backoff | Manual loops with custom exponential backoff code | Built-in automatic retries with configurable exponential delay |
| Memory Cleanup & Leaks | High leak potential when unmounting during fetch | Automatic garbage collection via configurable gcTime |
The Layer Shift: Remote State Synchronization#
With React Query, the mindset shifts from "fetching data" to "synchronizing data." The component is relieved from managing caching rules and data consistency. This logic moves up to a dedicated infrastructure layer.
Cache lifecycle#
TanStack Query manages data through four sequential states:
- Fresh: recent data, will not be automatically revalidated.
- Stale: data flagged as outdated; revalidates in background on the next trigger (window focus, interval, remount).
- Inactive: no component is subscribed to this query key; data remains in cache until
gcTimeexpires. - Garbage Collected: data evicted from memory. The next access will trigger a fresh fetch from scratch.
Usequery: core fetching and syncing patterns#
Below is a typical implementation using TypeScript typing, runtime payload validation with Zod to prevent corrupted states, and dynamic cache lifetimes:
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
// 1. Define schema validation
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const UsersArraySchema = z.array(UserSchema);
type User = z.infer<typeof UserSchema>;
// 2. Typed and validated fetcher
const fetchUsers = async (status?: string): Promise<User[]> => {
const url = status ? `/api/users?status=${status}` : "/api/users";
const res = await fetch(url);
if (!res.ok) throw new Error("API communication failure");
const data = await res.json();
return UsersArraySchema.parse(data); // Strict validation
};
export default function Users({ status }: { status?: string }) {
const { data, isLoading, isFetching, error } = useQuery<User[], Error>({
queryKey: ["users", status],
queryFn: () => fetchUsers(status),
// Cache Lifecycle Governance
staleTime: 1000 * 60 * 5, // Data considered fresh for 5 minutes
gcTime: 1000 * 60 * 30, // Keep inactive cached data for 30 minutes
retry: 2, // Retry up to 2 times before failing
// Conditional Fetching: Runs only when status is defined
enabled: !!status,
// Note: `null`, `undefined`, `0`, and `""` are also falsy and disable the query.
// Use `enabled: status !== undefined` to allow empty strings as a valid fetch trigger.
// Active Polling (background revalidation every 30 seconds)
refetchInterval: 30000,
refetchIntervalInBackground: false, // Pauses polling when tab loses focus
refetchOnWindowFocus: true, // Revalidates when browser window refocuses
});
if (isLoading) return <p>Loading data (first fetch)...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
{isFetching && <span>Updating data in background...</span>}
<ul>
{data?.map(user => (
<li key={user.id}>{user.name} ({user.email})</li>
))}
</ul>
</div>
);
}
Dynamic dependencies in queryKey#
The queryKey acts as the dependency array of useEffect: whenever a value inside it changes, TanStack Query automatically triggers a new fetch. This is what makes ["users", status] such a powerful pattern:
// Whenever `status` changes (e.g., "active" → "inactive"),
// the query re-executes automatically with the new parameter
const { data } = useQuery({
queryKey: ["users", status], // status becomes part of the cache key
queryFn: () => fetchUsers(status),
});
// useEffect analogy:
useEffect(() => {
fetchUsers(status);
}, [status]); // same behavior, but without cache, deduplication, or retry
Whenever you need data to refetch when filters, pages, or search parameters change, include those values in the queryKey. TanStack Query guarantees that each unique key combination maintains its own isolated cache.
Layout shift prevention#
To prevent layout shifts or loading spinners during pagination or dynamic query changes, the placeholderData option keeps showing cached data while the next request completes:
import { keepPreviousData } from "@tanstack/react-query";
const { data } = useQuery({
queryKey: ["users", page],
queryFn: () => fetchUsersPage(page),
placeholderData: keepPreviousData, // Avoids flashes of empty states
});
2. Practical Migration: useEffect vs. TanStack Query (Before vs. After)#
Before: verbose boilerplates with useEffect#
To handle race conditions (using a boolean cancellation flag) and manually control loading and error states:
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let active = true;
setLoading(true);
fetch('/api/users')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch users');
return res.json();
})
.then((data) => {
if (active) {
setUsers(data);
setLoading(false);
}
})
.catch((err) => {
if (active) {
setError(err);
setLoading(false);
}
});
return () => {
active = false;
};
}, []);
if (loading) return <Skeleton />;
if (error) return <ErrorComponent message={error.message} />;
return <UserTable data={users} />;
}
After: declarative with tanstack query (v5)#
import { useQuery } from '@tanstack/react-query';
function UserList() {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then((r) => {
if (!r.ok) throw new Error('Network error');
return r.json();
}),
});
if (isLoading) return <Skeleton />;
if (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return <ErrorComponent message={message} />;
}
return <UserTable data={users ?? []} />;
}
3. Strong Typing with TypeScript and Safe Error Handling#
In TanStack Query v5, the query error property is typed as unknown by default. This forces developers to narrow the error type before accessing properties like .message, preventing runtime failures.
Type-safe data and error handling#
You can explicitly type query hooks or allow TypeScript to infer types from the fetching function:
interface User {
id: number;
name: string;
email: string;
}
// Hook infers output as `User[] | undefined`
const { data, isLoading, error } = useQuery<User[], Error>({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('API failure');
return res.json();
},
staleTime: 1000 * 60 * 5, // Cache stays fresh for 5 minutes
});
if (error) {
// TypeScript correctly infers error is of type Error
return <p>Error occurred: {error.message}</p>;
}
Error boundaries integration#
If you prefer handling errors with standard React Error Boundaries, set the throwOnError parameter:
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
throwOnError: true, // Propagates failures up to the React Error Boundary
});
4. Conditional Queries, Dependent Fetching, and Suspense Support#
Often, data fetching depends on external parameters (like a selected ID) or should only execute after a user action. The enabled flag manages this requirement:
const { data: details, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
enabled: !!userId, // Query only executes when userId is valid and truthy
});
Declarative Data Fetching with Suspense#
TanStack Query supports native integration with React's <Suspense> to simplify loading state management. Instead of checking isLoading in every component, you delegate the loading state to a Suspense boundary higher in the tree:
import { useSuspenseQuery } from "@tanstack/react-query";
import { Suspense } from "react";
// Hook: use useSuspenseQuery (not useQuery) for Suspense mode
function UserList({ status }: { status: string }) {
const { data } = useSuspenseQuery({
queryKey: ["users", status],
queryFn: () => fetchUsers(status),
});
// data is always defined here (never undefined)
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
// Parent: handles loading state via boundary
export function UsersPage({ status }: { status: string }) {
return (
<Suspense fallback={<p>Loading users...</p>}>
<UserList status={status} />
</Suspense>
);
}
5. Write Operations: Mutations with useMutation and Cache Invalidation#
Fetching data is only half of a typical CRUD cycle. For write operations (POST, PUT, DELETE), TanStack Query offers the useMutation hook, which coordinates cache invalidations to refresh the UI.
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface NewUser {
name: string;
email: string;
}
function CreateUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newUser: NewUser) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
if (!res.ok) throw new Error('Failed to create user');
return res.json();
},
onSuccess: () => {
// Invalidates cache and forces all active components subscribed to 'users' to refetch
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
return (
<div>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
<button
onClick={() => mutation.mutate({ name: 'Percio', email: '[email protected]' })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Saving...' : 'Add User'}
</button>
</div>
);
}
Advanced Mutation Control and Cache Invalidation#
For write requests (POST, PUT, DELETE), React Query provides the useMutation hook. Unlike queries, mutations do not fire implicitly and are vital for managing side-effects, cache updates, and rollbacks.
Quick cache invalidation after mutation#
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface CreateUserInput {
name: string;
email: string;
}
export function CreateUserButton() {
const queryClient = useQueryClient();
const mutation = useMutation<User, Error, CreateUserInput>({
mutationFn: async (newUser) => {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newUser),
});
if (!res.ok) throw new Error("Error creating user");
return res.json();
},
onSuccess: () => {
// Invalidate query cache and force background refresh to stay in sync
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
return (
<button
onClick={() => mutation.mutate({ name: "John", email: "[email protected]" })}
disabled={mutation.isPending}
>
{mutation.isPending ? "Saving..." : "Create User"}
</button>
);
}
Optimistic updates with rollback support#
On high-latency networks, we can update the local UI cache immediately and revert (rollback) if the API fails:
const mutation = useMutation({
mutationFn: updateUserData,
// Runs immediately on mutate trigger
onMutate: async (newData) => {
// 1. Cancel active query refreshes to protect our optimistic state
await queryClient.cancelQueries({ queryKey: ["users"] });
// 2. Snapshot current cache (for emergency rollback)
const previousUsers = queryClient.getQueryData<User[]>(["users"]);
// 3. Optimistically update local cache
queryClient.setQueryData<User[]>(["users"], (old) =>
old ? old.map(u => u.id === newData.id ? { ...u, ...newData } : u) : []
);
// Return context with rollback reference
return { previousUsers };
},
// Revert cache if mutation fails
onError: (err, newData, context) => {
if (context?.previousUsers) {
queryClient.setQueryData(["users"], context.previousUsers);
}
},
// Force final synchronization regardless of success/error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
6. Global Configuration of QueryClient and DevTools#
Configuring default query options globally avoids repetitive setup boilerplate across individual queries.
Initializing the client with custom cache policies#
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // Data is considered fresh for 5 minutes
gcTime: 1000 * 60 * 30, // Kept in garbage collector cache for 30 minutes
retry: 3, // Retry failed requests 3 times
refetchOnWindowFocus: true, // Automatically refetch when window regains focus
refetchOnReconnect: true, // Refetch when browser re-establishes network connection
},
},
});
Enabling the devtools panel#
The official DevTools utility is essential to inspect cache states, view active queries, and debug mutations at runtime:
pnpm add @tanstack/react-query-devtools
Mount the DevTools helper inside the provider block at your application entry point:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
7. Performance Optimization, Cache Governance (staleTime vs. gcTime), and UX#
A. Polling (real-time updates)#
For control panels or telemetry dashboards that require persistent periodic background updates:
const { data } = useQuery({
queryKey: ['dashboard-status'],
queryFn: fetchStatus,
refetchInterval: 10000, // Refetch data every 10 seconds
refetchIntervalInBackground: false, // Stop polling when tab is inactive/backgrounded
});
B. Smooth pagination with placeholderData#
Prevent disruptive layout shifts (and flicker from loading screens) when switching pages. The placeholderData attribute displays old data while retrieving the new batch:
import { keepPreviousData } from '@tanstack/react-query';
function PaginatedList({ page }: { page: number }) {
const { data, isPlaceholderData } = useQuery({
queryKey: ['users', page],
queryFn: () => fetch(`/api/users?page=${page}`).then(r => r.json()),
placeholderData: keepPreviousData, // Displays old page opaque while loading next page
});
return (
<div style={{ opacity: isPlaceholderData ? 0.6 : 1, transition: 'opacity 0.2s' }}>
<UserTable data={data?.results ?? []} />
</div>
);
}
C. Data transformations using select#
Transform api response payloads on the fly before delivering them to your component. This improves performance (the component only re-renders if the transformed data changes):
const { data: activeCount } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (users: User[]) => users.filter(u => u.status === 'active').length,
});
8. Runtime Schema Validation with Zod#
If your backend API returns unexpected payloads or corrupted object schemas, your frontend static TypeScript definitions cannot prevent runtime bugs. Validating response payloads using Zod ensures data integrity:
import { z } from 'zod';
import { useQuery } from '@tanstack/react-query';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const UserListSchema = z.array(UserSchema);
function useValidatedUsers() {
return useQuery({
queryKey: ['validated-users'],
queryFn: async () => {
const res = await fetch('/api/users');
const data = await res.json();
// Validates and strictly parses structure, throwing if constraints fail
return UserListSchema.parse(data);
},
});
}
9. Decision Matrix: When to Still Use useEffect and Ecosystem Alternatives#
1. Centralized keys (query key factories)#
Avoid distributing raw strings across codebase queries to prevent typos during cache invalidations:
export const queryKeys = {
users: {
all: ['users'] as const,
list: (filter: string) => [...queryKeys.users.all, 'list', filter] as const,
detail: (id: number) => [...queryKeys.users.all, 'detail', id] as const,
},
};
// Usage:
useQuery({ queryKey: queryKeys.users.detail(1), queryFn: ... })
2. Ecosystem comparison#
While TanStack Query is widely adopted, selecting the appropriate tool depends on project size and context:
| Library | Weight (Gzipped) | Recommended Use Cases | Key Features |
|---|---|---|---|
| TanStack Query | ~13kB | Complex SaaS products, robust dashboard panels | Fine-grained cache policies, mutations, developer ecosystem, and devtools. |
| SWR (Vercel) | ~4kB | Lightweight pages, simple Next.js applications | Lightweight foot-print, minimal API, focus on lightweight revalidation. |
| RTK Query | Included in RTK | Enterprise setups already using Redux Toolkit | Integrates query state and caching directly into your existing Redux store. |
| Apollo Client | ~30kB | Apps consuming GraphQL APIs exclusively | Normalizes client-side caching for complex Graph queries. |
Source of library bundle sizes (Gzipped): Bundlephobia.
Global Configuration and DevTools Reference#
To centralize query infrastructure, wrap your root with QueryClientProvider and enable the query DevTools, which are highly useful for auditing keys, states (stale, fetching, inactive), and caching cycles.
Installing devtools#
pnpm add @tanstack/react-query-devtools
App configuration (App.tsx)#
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes default
gcTime: 1000 * 60 * 30, // 30 minutes garbage collection
retry: 1,
refetchOnWindowFocus: true,
},
},
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
{/* DevTools will render during local development */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Decision Rule (No Dogma)#
| Use Case | Recommended Tool | Technical Justification |
|---|---|---|
| REST/GraphQL API consumption | React Query | Remote state with cache, deduplication, and revalidation |
| Server state synchronization | React Query | Reduces Context/Redux complexity for external data |
| Browser listeners (resize/scroll) | useEffect | Runtime side effect, not remote state |
| Timers/intervals | useEffect | Lifecycle control and cleanup (clearInterval) |
| DOM/ref/External lib integration | useEffect | Local component orchestration |
10. Rapid State Reference, Operational Checklist, and Strategic Conclusions#
Ecosystem Alternatives#
While TanStack Query is the de-facto standard in the React ecosystem, other equivalent tools exist:
- SWR (Vercel): Extremely lightweight and simple, ideal for small applications. Focuses heavily on the native Stale-While-Revalidate spec.
- RTK Query (Redux Toolkit): Recommended if your application already integrates Redux for local state. Avoids carrying two robust state engines.
- Apollo Client: A mature caching solution for Graphql-focused systems.
11. Frequently Asked Questions (FAQ): useEffect vs TanStack Query#
When should I still use useEffect instead of React Query?#
I use useEffect strictly for synchronizing components with local browser side effects that do not involve remote server state. Classic examples include: binding to global window events such as scroll or resize (window.addEventListener('resize')), managing intervals and timers that require explicit cleanup (setInterval / clearInterval), imperatively manipulating DOM references (ref.current.focus()), or orchestrating third-party non-React libraries (such as video players or local canvas rendering). For any asynchronous data originating from HTTP, REST, or GraphQL APIs, TanStack Query is the right tool.
Does TanStack Query replace global state managers like Zustand or Redux?#
In most modern React applications, yes. In my development workflows and architecture audits, roughly 80% of what developers traditionally shove into Redux or Zustand stores is actually remote server state (user profiles, product catalogs, customer records, and API responses). Once remote state and caching are offloaded to TanStack Query, global client state libraries are only needed for pure client-side state (such as toggled dark mode, active modal states, or uncommitted multi-step form steps), dramatically reducing state management overhead.
How does TanStack Query prevent race conditions in fast search and filter inputs?#
TanStack Query manages every request under a deterministic queryKey array (for instance, ['users', searchQuery]). When a user types rapidly, each keystroke triggers a new distinct query key. If a previous network request finishes out of order after a subsequent query was initiated, TanStack Query ignores or cancels the outdated in-flight request via its automatic AbortSignal integration. This guarantees that only the most recently triggered query updates the component UI, eliminating UI flickering and stale overwrites.
What is the practical difference between isLoading and isFetching for UI/UX?#
From a user experience standpoint, the difference is critical: isLoading evaluates to true strictly during the very first query execution when no cached data exists in memory (ideal for rendering full-page skeleton loaders). Meanwhile, isFetching evaluates to true whenever any network request is in-flight for that query key, including silent background revalidations on window focus or intervals. I use isFetching to render subtle inline indicators (such as a tiny corner spinner) without taking away the content the user is already interacting with.
Does React Query introduce heavy bundle overhead to a React application?#
No. The core @tanstack/react-query package is fully tree-shakeable and highly modular. In production builds, it adds only about 12 to 13 KB (gzipped) to your application bundle. This small footprint is quickly offset by the deletion of dozens of hand-rolled data hooks, redundant Redux/Zustand slice boilerplate, and hundreds of lines of fragile manual retry and cleanup code.
Production Architecture Takeaways#
Using useEffect for data fetching works for basic proof of concepts but introduces bugs when scaling. For modern React software, adopting TanStack Query eliminates boilerplate states, improves user experience through cache revalidations, and eradicates asynchronous concurrency issues.
Related Articles and Portuguese Edition: - Read the original Portuguese deep-dive: TanStack Query vs. useEffect: O Guia Definitivo de Data Fetching, Governança de Estado e Performance no React. - Exploring fullstack performance and developer tool diagnostics? Read my deep dive on Troubleshooting Next.js 16 Turbopack Logs. - For real-time infrastructure network monitoring and automated diagnostic tooling, check out NEXUS Platform.
Was this article helpful?
Leave a quick reaction to help prioritize future technical guides:
This post is licensed under CC BY-NC.



Comments
Join the discussion below.
0 comments