React Data Fetching 101: From useEffect to useQuery - A Complete Tutorial
What is useQuery and Why Switch from useEffect?
useQuery is a powerful hook from TanStack Query (formerly React Query) that simplifies data fetching in React applications. While useEffect has been the traditional way to fetch data, it comes with several limitations that useQuery elegantly solves:
- No built-in caching - useEffect fetches data every time, leading to redundant network requests
- Manual error handling - You need to manage error states yourself
- Complex loading states - Requires manual tracking of loading states
- No background updates - Data doesn’t stay fresh automatically
TanStack Query transforms these pain points into automatic, intelligent data management with minimal code.
Step 1: Installation and Setup
Install TanStack Query
First, install the core library and devtools for debugging:
npm install @tanstack/react-query @tanstack/react-query-devtools
Why this step matters: The devtools are essential for visualizing your queries and debugging data fetching issues.
Set Up QueryClient Provider
Wrap your entire application with QueryClientProvider in your main entry file (main.tsx or index.js):
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
// Create a client outside of any component
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
)
Why this setup is crucial: The QueryClientProvider makes the query client available to all child components, enabling consistent data fetching behavior across your app. The devtools provide a visual interface to monitor your queries in real-time.
Step 2: Your First useQuery - Basic Data Fetching
Let’s compare the old useEffect approach with the new useQuery approach:
The Old Way (useEffect)
import React, { useState, useEffect } from 'react'
function UsersList() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true)
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error('Network response was not ok')
}
const data = await response.json()
setUsers(data)
} catch (error) {
setError(error)
} finally {
setLoading(false)
}
}
fetchUsers()
}, []) // Empty dependency array
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
The New Way (useQuery)
import { useQuery } from '@tanstack/react-query'
// Separate query function for reusability
const fetchUsers = async () => {
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
}
function UsersList() {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
Key differences explained:
- Less code: No manual state management for loading, error, and data states
- queryKey: A unique identifier for caching and tracking this query
- queryFn: The function that fetches your data - must return a Promise
- Automatic caching: Data is cached and reused across components
Step 3: Understanding Query Keys
Query keys are crucial for useQuery functionality. Think of them as dependency arrays for your data fetching:
// Simple string key
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
// Key with parameters
const { data } = useQuery({
queryKey: ['users', userId], // Include variables in the key
queryFn: () => fetchUser(userId),
})
// Complex key with filters
const { data } = useQuery({
queryKey: ['todos', { status: 'completed', page: 1 }],
queryFn: () => fetchTodos({ status: 'completed', page: 1 }),
})
Why this matters: When the query key changes, React Query automatically refetches the data. This replaces the need for complex useEffect dependency arrays.
Step 4: Error Handling Made Simple
React Query provides multiple ways to handle errors elegantly:
function UserProfile({ userId }) {
const { data, error, isError, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
retry: 3, // Automatically retry 3 times on failure
retryDelay: (attempt) => Math.pow(2, attempt) * 1000, // Exponential backoff
})
if (isLoading) return <div>Loading user...</div>
// Simple error handling
if (isError) {
return (
<div className="error">
<h3>Something went wrong!</h3>
<p>{error.message}</p>
</div>
)
}
return (
<div>
<h2>{data.name}</h2>
<p>{data.email}</p>
</div>
)
}
Advanced error handling with custom error boundaries:
// Global error handling
const queryClient = new QueryClient({
defaultOptions: {
queries: {
throwOnError: (error) => error.response?.status >= 500, // Only server errors go to Error Boundary
},
},
})
Step 5: Refetching and Cache Invalidation
One of the most powerful features is intelligent data refetching:
function TodoList() {
const queryClient = useQueryClient()
const { data: todos, refetch } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: true, // Refetch when user returns to tab
})
const handleRefresh = () => {
refetch() // Manual refetch
}
const handleInvalidate = () => {
// Mark data as stale and refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
return (
<div>
<button onClick={handleRefresh}>Refresh</button>
<button onClick={handleInvalidate}>Force Update</button>
{todos?.map(todo => (
<div key={todo.id}>{todo.title}</div>
))}
</div>
)
}
When to use each approach:
- refetch(): Manual refresh triggered by user action
- invalidateQueries(): Mark data as stale, useful after mutations
Step 6: Advanced Configuration Options
Fine-tune your queries for optimal performance:
function ProductList() {
const { data, isFetching, isStale } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
// Caching configuration
staleTime: 5 * 60 * 1000, // 5 minutes until data is considered stale
cacheTime: 10 * 60 * 1000, // 10 minutes until data is removed from cache
// Refetching configuration
refetchOnMount: true, // Refetch when component mounts
refetchOnWindowFocus: true, // Refetch when window gets focus
refetchInterval: 30000, // Poll every 30 seconds
// Error handling
retry: 3, // Retry failed requests 3 times
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
// Conditional fetching
enabled: !!userId, // Only run query if userId exists
})
return (
<div>
{isFetching && <div className="spinner">Updating...</div>}
{isStale && <div className="indicator">Data might be outdated</div>}
{data?.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
Step 7: Creating Custom Query Hooks
Best practice: Create reusable custom hooks for your queries:
// hooks/useUsers.js
import { useQuery } from '@tanstack/react-query'
export const useUsers = (filters = {}) => {
return useQuery({
queryKey: ['users', filters],
queryFn: () => fetchUsers(filters),
staleTime: 5 * 60 * 1000,
})
}
export const useUser = (userId) => {
return useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // Only fetch if userId exists
})
}
// Component usage
function UserComponent({ userId }) {
const { data: user, isLoading, error } = useUser(userId)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error loading user</div>
return <div>{user.name}</div>
}
Why custom hooks are essential:
- Centralized data fetching logic
- Consistent query keys across your app
- Easy to modify query settings in one place
- Better testability and maintainability
Common Pitfalls and How to Avoid Them
❌ Pitfall 1: Overusing useQuery
// DON'T: Create thousands of query subscribers
function BadExample() {
return (
<div>
{items.map(item => (
<ItemComponent key={item.id} itemId={item.id} />
))}
</div>
)
}
function ItemComponent({ itemId }) {
// This creates a separate query for each item - performance issue!
const { data } = useQuery({
queryKey: ['item', itemId],
queryFn: () => fetchItem(itemId),
})
return <div>{data?.name}</div>
}
// ✅ DO: Hoist the query and pass data down
function GoodExample() {
const { data: items } = useQuery({
queryKey: ['items'],
queryFn: fetchAllItems,
})
return (
<div>
{items?.map(item => (
<ItemComponent key={item.id} item={item} />
))}
</div>
)
}
function ItemComponent({ item }) {
return <div>{item.name}</div>
}
❌ Pitfall 2: Inconsistent Query Keys
// DON'T: Inconsistent keys
const { data } = useQuery({
queryKey: ['user-profile', userId], // Different format
queryFn: () => fetchUser(userId),
})
const { data: settings } = useQuery({
queryKey: ['userSettings', userId], // Different format
queryFn: () => fetchUserSettings(userId),
})
// ✅ DO: Consistent key patterns
const { data } = useQuery({
queryKey: ['users', userId, 'profile'],
queryFn: () => fetchUser(userId),
})
const { data: settings } = useQuery({
queryKey: ['users', userId, 'settings'],
queryFn: () => fetchUserSettings(userId),
})
❌ Pitfall 3: Not Handling Loading States Properly
// DON'T: Showing loading for cached data
function BadLoading() {
const { data, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
if (isLoading) return <div>Loading...</div> // Shows even for cached data!
return <PostList posts={data} />
}
// ✅ DO: Use appropriate loading states
function GoodLoading() {
const { data, isLoading, isFetching, isPreviousData } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
keepPreviousData: true, // Show stale data while fetching new data
})
return (
<div>
{isFetching && <div className="spinner">Updating...</div>}
{isLoading ? (
<div>Loading posts...</div>
) : (
<PostList posts={data} isStale={isPreviousData} />
)}
</div>
)
}
Best Practices Summary
- Always use consistent query key patterns
- Create custom hooks for reusability
- Leverage caching with appropriate staleTime and cacheTime
- Use the enabled option for conditional queries
- Handle errors at the appropriate level (component vs global)
- Don’t overuse useQuery - hoist when possible
- Use React Query Devtools for debugging
Conclusion
Switching from useEffect to useQuery dramatically simplifies data fetching in React applications. You get automatic caching, background updates, error handling, and loading states with minimal code. The key is understanding query keys, proper error handling, and following best practices to avoid common pitfalls.
Start small by replacing one useEffect data fetch with useQuery, then gradually adopt it throughout your application. The productivity gains and improved user experience make it an essential tool for modern React development.
Tags:
Related Posts
Design Systems 101: A Beginner's Guide to Building Consistency from Scratch
Learn how to build a design system from scratch and create consistent, professional interfaces. This beginner-friendly guide covers everything from color palettes and typography to components and documentation.
Complete TanStack Router Tutorial: Building a Todo App
Learn TanStack Router from scratch by building a complete Todo application. This comprehensive tutorial covers type-safe routing, data loading, mutations, navigation patterns, and error handling with practical examples.