Roberto Tomé

ROBERTO TOMÉ

React Data Fetching 101: From useEffect to useQuery - A Complete Tutorial
Tutorials

React Data Fetching 101: From useEffect to useQuery - A Complete Tutorial

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

  1. Always use consistent query key patterns
  2. Create custom hooks for reusability
  3. Leverage caching with appropriate staleTime and cacheTime
  4. Use the enabled option for conditional queries
  5. Handle errors at the appropriate level (component vs global)
  6. Don’t overuse useQuery - hoist when possible
  7. 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:

React useEffect useQuery

Share this post:

React Data Fetching 101: From useEffect to useQuery - A Complete Tutorial