You've just finished building your first React component that fetches data. You used the trusty combination of fetch
and useEffect
- it works, but something feels off. Maybe you've noticed your app re-fetching data unnecessarily, or you're drowning in loading states and error handling code. If this sounds familiar, you're not alone.
As a senior developer who's been through these trenches, let me explain why libraries like Tanstack Query (formerly React Query) have become the go-to solution for data fetching in modern React applications.
The Problem with Traditional Data Fetching
Let's look at a typical data fetching setup using fetch
and useEffect
:
function UserProfile({ userId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
const json = await response.json();
setData(json);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return <div>{data.name}</div>;
}
This code might look familiar. It works, but it comes with several hidden problems:
You're managing three pieces of state (
data
,loading
,error
) for every data fetchThere's no built-in caching - if the component remounts, it fetches again
If the user navigates away and back, they see a loading state again
No automatic background updates or refetching on window focus
No way to know if the data is stale
Error handling and retries need to be implemented manually
The React team has even noted that while useEffect
can be used for data fetching, it's not the ideal solution. According to the React documentation, useEffect
is primarily meant for synchronization with external systems, not data fetching.
Enter Tanstack Query
Here's how the same component looks with Tanstack Query:
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{data.name}</div>;
}
Not only is this code more concise, but it comes with powerful features out of the box:
1. Automatic Caching and Background Updates
Tanstack Query maintains a cache of your data and automatically manages its lifecycle. When you request the same data again, it:
Returns cached data immediately
Fetches fresh data in the background
Updates the UI automatically when new data arrives
Allows you to configure how and when data should be considered stale
2. Smart Request Deduplication
If multiple components request the same data simultaneously, Tanstack Query will only make one network request and share the result with all components. This is particularly valuable in larger applications where the same data might be needed in multiple places.
3. Built-in Loading and Error States
Instead of managing multiple pieces of state manually, Tanstack Query provides a unified interface for handling loading, error, and success states:
const { data, isLoading, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
4. Automatic Retries and Error Handling
When a request fails, Tanstack Query will automatically retry it with exponential backoff, and you can configure the retry behavior to match your needs:
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
retry: 3, // Will retry failed requests 3 times
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
});
Advanced Features That Make Life Easier
Optimistic Updates
One of the most powerful features of Tanstack Query is optimistic updates. Instead of waiting for the server response, you can update the UI immediately while the mutation is in progress:
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async newTodo => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot the previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update to the new value
queryClient.setQueryData(['todos'], old => [...old, newTodo]);
// Return a context object with the snapshotted value
return { previousTodos };
},
onError: (err, newTodo, context) => {
// If the mutation fails, use the context returned from onMutate to roll back
queryClient.setQueryData(['todos'], context.previousTodos);
},
});
Prefetching and Parallel Queries
Tanstack Query makes it easy to prefetch data before it's needed:
// Prefetch data for the next page
const prefetchNextPage = async () => {
await queryClient.prefetchQuery({
queryKey: ['todos', page + 1],
queryFn: () => fetchTodoPage(page + 1),
});
};
// Run multiple queries in parallel
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
Infinite Queries for Pagination
Implementing infinite scroll becomes trivial:
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjectPage,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
});
When to Make the Switch
You should consider switching to Tanstack Query when:
You find yourself writing the same data-fetching boilerplate code repeatedly
Your application needs real-time updates or background refetching
You want better control over caching and invalidation
You need advanced features like optimistic updates or infinite scrolling
You're building a larger application where data management becomes complex
Integration with Modern React Frameworks
Tanstack Query works seamlessly with modern React frameworks like Next.js. While Next.js provides its own data fetching methods, Tanstack Query can complement these features, especially for client-side data management:
// pages/users/[id].js
export default function UserPage({ initialData }) {
const { data } = useQuery({
queryKey: ['user', id],
queryFn: fetchUser,
initialData, // Hydrate from SSR data
});
return <UserProfile user={data} />;
}
// Get initial data during SSR
export async function getServerSideProps({ params }) {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['user', params.id],
queryFn: () => fetchUser(params.id),
});
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
Conclusion
While fetch
and useEffect
might seem simpler at first, they quickly become unwieldy as your application grows. Tanstack Query provides a robust solution that handles the complexities of data fetching, caching, and state management, allowing you to focus on building features rather than managing data-fetching logic.
The initial learning curve is worth the investment, as it will save you countless hours of debugging and implementing features that come out of the box with Tanstack Query. As your application grows, you'll appreciate having a battle-tested solution that handles the intricacies of data management for you.
Remember, tools like Tanstack Query aren't just about writing less code - they're about writing more maintainable, performant, and user-friendly applications. The next time you reach for fetch
and useEffect
, consider whether Tanstack Query might be a better fit for your needs.