TanStack Query - The Data Fetching Problem You Didn't Know You Had (Part 1/2)
You Think You Know How to Fetch Data in React
You’ve made it this far in your React journey. You understand useState
for component memory and useEffect
for side effects. You’ve probably built several components that fetch data from APIs.
The pattern feels natural: drop an async
function inside useEffect
, call fetch()
, store the result with useState
. It works, the data appears, and you feel accomplished. You’ve built a component that communicates with the internet.
Here’s what you actually built: a fragile, inefficient system that will break down under real-world conditions.
This common pattern—what I call the “useEffect data-fetching antipattern”—is responsible for countless bugs in production React applications. It works in development the same way a paper airplane works until you try to fly across the country with it.
Why are we building it first? Because you need to understand exactly what goes wrong before you can appreciate the solution. We’re going to construct this flawed approach methodically, with proper error handling and loading states, so you can see every crack in the foundation.
Then, in the next article, we’ll tear it down and rebuild it properly using TanStack Query.
Let’s build a “complete” data-fetching component and discover why it’s fundamentally broken.
The Crime Scene: A Simple “To-Do” List Component
Let’s start with a basic React setup. Imagine you’re building the billionth To-Do list application. The task is simple: fetch a list of to-dos from a public API (jsonplaceholder
) and display them.
Here’s our starting point, a clean React component using TypeScript and Vite.
App.tsx
import "./App.css";
function App() {
return (
<div className="container">
<h1>My Useless To-Do List</h1>
<TodosComponent />
</div>
);
}
// Our star of the show. Or, victim.
function TodosComponent() {
// We'll fetch and display to-dos here.
return <div>{/* Magic will happen here soon */}</div>;
}
export default App;
(The CSS can be the same as the previous articles, I’m not including it here for brevity. You know how to make things look half-decent.)
Act I: The Naive Fetch
Your first instinct, the one you learned from that 10-minute YouTube tutorial, is to do this:
import { useEffect, useState } from "react";
// Define the shape of our data. Don't be a savage, use TypeScript.
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
function TodosComponent() {
const [todos, setTodos] = useState<Todo[]>([]);
useEffect(() => {
const fetchTodos = async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos"
);
const data = await response.json();
setTodos(data);
};
fetchTodos();
}, []); // Run this once on mount. Classic.
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
You run this, and it works. The list appears. Mission accomplished, right?
Not quite. What happens during the network request? Your component renders an empty <ul>
. Users see blank space. On slow connections, they see blank space for an uncomfortably long time, wondering if your app crashed or if they should refresh the page.
This creates a poor user experience. Professional applications need loading states to communicate what’s happening.
Act II: Acknowledging Reality (The Loading State)
Let’s add a state to track whether we’re currently begging the server for data.
function TodosComponent() {
const [todos, setTodos] = useState<Todo[]>([]);
const [isLoading, setIsLoading] = useState(true); // Default to true!
useEffect(() => {
const fetchTodos = async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos"
);
const data = await response.json();
setTodos(data);
setIsLoading(false); // We're done, set to false.
};
fetchTodos();
}, []);
if (isLoading) {
return <div>Loading, hold your horses...</div>;
}
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
Better. Now our user gets a friendly “Loading…” message. They know the app is doing something. But what happens if jsonplaceholder
is down? What if the URL has a typo? What if the network request fails for any of the other thousand reasons it can fail?
Right now, your fetch()
call will throw an error, your async
function will unceremoniously crash, you’ll never call setIsLoading(false)
, and your user will be staring at “Loading, hold your horses…” for the rest of eternity.
This isn’t just bad UX; it’s a straight-up bug. We need an error state.
Act III: Murphy’s Law (The Error State)
Time to wrap our fragile fetch call in the warm, safe embrace of a try...catch
block and add yet another piece of state.
function TodosComponent() {
const [todos, setTodos] = useState<Todo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); // Here we go again.
useEffect(() => {
// We have to reset state on each run, but here it only runs once.
// Still, it's good practice for more complex hooks.
setIsLoading(true);
setError(null);
const fetchTodos = async () => {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos"
);
// Fetch itself doesn't throw for bad HTTP statuses like 404 or 500.
// You have to check `response.ok`. Another pitfall you probably forgot.
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const data = await response.json();
setTodos(data);
} catch (e) {
// Now we can catch network errors or our thrown error.
setError(e as Error);
} finally {
// This runs whether it succeeded or failed. Perfect for loading state.
setIsLoading(false);
}
};
fetchTodos();
}, []); // The infamous empty dependency array.
if (isLoading) {
return <div>Loading, hold your horses...</div>;
}
// Now we handle the error state.
if (error) {
return <div>Error occurred: {error.message}</div>;
}
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
This is now a “complete” data-fetching component with three distinct states: loading, error, and success. You’ve written three useState
hooks and one substantial useEffect
to handle a single GET request.
The result looks comprehensive, but it’s built on a shaky foundation.
Why This Approach Falls Apart in Production
“But it works!” you might say. “It handles all the states correctly!”
The issue isn’t that it doesn’t work—it’s that this approach doesn’t scale and creates maintenance nightmares. Here’s why experienced developers avoid this pattern:
1. Excessive Boilerplate for Basic Functionality
You wrote ~15 lines of logic inside useEffect
before even rendering data. Three separate useState
hooks support one API call. Scale this across an application with dozens of endpoints, and you’ll have hundreds of lines of repetitive state management code.
Each component that needs data becomes a mini state management system. This violates the DRY principle and makes debugging a nightmare when similar patterns behave differently across your app.
2. No Caching Strategy
When TodosComponent
unmounts and remounts (user navigates away and back), useEffect
runs again, fetching identical data from the server. This wastes:
- Server resources and bandwidth
- User’s data allowance on mobile
- Time showing loading states for data we already had
Production applications need intelligent caching to avoid redundant requests.
3. Stale Data with No Freshness Strategy
What happens when data changes on the server while users view your component? Your component has no awareness of server-side updates. The data becomes stale indefinitely until users manually refresh the page.
Modern applications need strategies for:
- Background re-fetching when data might be stale
- Optimistic updates for better perceived performance
- Real-time synchronization for collaborative features
4. Race Conditions and Cleanup Issues
The empty dependency array []
seems safe, but it’s deceptive. In real applications, data fetching often depends on props or state. When dependencies change rapidly, you trigger multiple concurrent requests.
The race condition scenario:
- User searches for “react” → Request A starts
- User immediately searches for “vue” → Request B starts
- Request B completes first, updates state with “vue” results
- Request A completes later, overwrites state with “react” results
- User sees “react” results despite searching for “vue”
Proper cleanup requires AbortController
and careful state management:
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(url, {
signal: controller.signal,
});
// ... handle response
} catch (error) {
if (error.name !== "AbortError") {
setError(error);
}
}
};
fetchData();
return () => controller.abort(); // Cleanup function
}, [url]);
How many developers implement this correctly every time? Very few.
5. Memory Leaks and Component Lifecycle Issues
Another subtle but dangerous problem: what happens if your component unmounts while an async request is still in progress?
useEffect(() => {
const fetchData = async () => {
const response = await fetch("/api/todos");
const data = await response.json();
setTodos(data); // ⚠️ This runs even if component unmounted!
};
fetchData();
}, []);
If the user navigates away before the request completes, React will still try to call setTodos
on an unmounted component. This creates:
- Warning messages in development
- Memory leaks in production
- Potential crashes in strict mode
The proper solution requires cleanup:
useEffect(() => {
let isCancelled = false;
const fetchData = async () => {
const response = await fetch("/api/todos");
const data = await response.json();
if (!isCancelled) {
// Only update if still mounted
setTodos(data);
}
};
fetchData();
return () => {
isCancelled = true; // Cleanup function
};
}, []);
This is yet another pattern you need to remember and implement correctly every single time.
6. Missing Production Features
Your component still lacks:
- Retry logic for transient network failures
- Debouncing for user input-triggered requests
- Background re-fetching when the window regains focus
- Optimistic updates for better perceived performance
- Request deduplication when multiple components need the same data
- Error recovery strategies beyond showing error messages
The real problem: You’re not building a reusable data-fetching pattern. You’re building a maintenance liability that will accumulate technical debt as your application grows.
The Solution: Specialized Tools for Server State
For years, developers built custom hooks to abstract away these problems, creating slightly cleaner but still fundamentally flawed solutions. The React ecosystem needed a purpose-built tool for server state management.
Enter TanStack Query (formerly React Query).
TanStack Query isn’t just another data-fetching library—it’s a comprehensive server-state management solution. It recognizes that server state is fundamentally different from client state:
- Server state is remote, asynchronous, shared, and potentially stale
- Client state is local, synchronous, owned by your component, and always current
TanStack Query handles every problem we’ve discussed:
- ✅ Intelligent caching with configurable stale times
- ✅ Automatic background re-fetching and synchronization
- ✅ Race condition prevention and request deduplication
- ✅ Retry logic with exponential backoff
- ✅ Optimistic updates and error recovery
- ✅ Loading and error states with minimal boilerplate
What’s Next
In the next article, we’ll refactor our problematic TodosComponent
using TanStack Query. You’ll watch three useState
hooks and a complex useEffect
transform into a single, declarative hook. You’ll get more functionality with dramatically less code.
More importantly, you’ll understand why professional React applications choose specialized tools over hand-rolled solutions for complex problems like server state management.
The solution is coming. Get ready to see what modern data fetching should look like.