Skip to main content
    Back to all articles

    Mastering Data Fetching in React: From Hooks to Suspense

    React
    13 min read
    By Bahaj abderrazak
    Featured image for "Mastering Data Fetching in React: From Hooks to Suspense"

    Data fetching is a cornerstone of almost every modern web application. In React, the evolution of data fetching has gone from simple useEffect calls to sophisticated libraries and native platform features like Suspense. This post will guide you through the current best practices for managing server state and UI loading states.

    The Evolution of Data Fetching in React

    Initially, developers relied on componentDidMount or useEffect to fetch data, managing loading, error, and success states manually. This often led to repetitive boilerplate and complex logic, especially with interdependent fetches.

    Traditional useEffect Pattern:

          import React, { useState, useEffect } from 'react';
    
          function MyComponent() {
            const [data, setData] = useState(null);
            const [isLoading, setIsLoading] = useState(true);
            const [error, setError] = useState(null);
    
            useEffect(() => {
              const fetchData = async () => {
                try {
                  const response = await fetch('/api/data');
                  if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                  }
                  const result = await response.json();
                  setData(result);
                } catch (err) {
                  setError(err);
                } finally {
                  setIsLoading(false);
                }
              };
    
              fetchData();
            }, []); // Empty dependency array means run once on mount
    
            if (isLoading) return <div>Loading...</div>;
            if (error) return <div>Error: {error.message}</div>;
            return <div>{JSON.stringify(data)}</div>;
          }

    While functional, this approach quickly becomes unwieldy for complex applications.

    TanStack Query (formerly React Query): The King of Server State

    TanStack Query has emerged as the industry standard for managing server state in React. It's not just a data fetcher; it's a comprehensive library that handles caching, background refetching, synchronization, pagination, infinite loading, and optimistic updates out-of-the-box.

    Key Concepts of TanStack Query:

    • Queries: For GET requests (data retrieval).
    • Mutations: For POST, PUT, DELETE requests (data modification).
    • Query Keys: Unique identifiers for your queries, used for caching and invalidation.
    • Stale-while-revalidate: Data is immediately shown from cache, then refetched in the background.

    Example with TanStack Query:

          import { useQuery } from '@tanstack/react-query';
    
          async function fetchTodos() {
            const res = await fetch('/api/todos');
            if (!res.ok) throw new Error('Failed to fetch todos');
            return res.json();
          }
    
          function TodoList() {
            const { data: todos, isLoading, isError, error } = useQuery({
              queryKey: ['todos'],
              queryFn: fetchTodos,
              staleTime: 5 * 60 * 1000, // Data is considered fresh for 5 minutes
            });
    
            if (isLoading) return <div>Loading todos...</div>;
            if (isError) return <div>Error: {error.message}</div>;
    
            return (
              <ul>
                {todos.map(todo => (
                  <li key={todo.id}>{todo.title}</li>
                ))}
              </ul>
            );
          }

    Notice how much cleaner the component logic becomes, with TanStack Query handling the complex states.

    React Suspense for Data Fetching: A Glimpse into the Future

    React Suspense allows your components to "wait" for something before rendering. While primarily used for code splitting (React.lazy), its future lies in data fetching, allowing components to declaratively express their data dependencies and trigger loading states higher up in the component tree.

    Important Note: As of early 2025, Suspense for data fetching with client-side fetching libraries like TanStack Query typically requires an SSR/Streaming setup (like Next.js React Server Components or the experimental use hook) to fully leverage its potential without "waterfalling" fetches. However, it's becoming more and more integrated.

    How it Works (Conceptual):

    Instead of managing isLoading state manually, you would wrap a component that 'suspends' (throws a promise while fetching) with a <Suspense fallback={...}> boundary.

    Example with Suspense (Next.js/RSC-like or experimental context):

          // This is a simplified example illustrating the concept,
          // actual implementation depends on framework/library support.
    
          // Some data fetching utility that integrates with Suspense
          // (e.g., a RSC component fetching data, or a client component using a Suspense-ready hook)
          async function fetchUserProfile(userId) {
            // Simulate async data fetching
            return new Promise(resolve => setTimeout(() => {
              resolve({ id: userId, name: 'Alice', email: 'alice@example.com' });
            }, 1000));
          }
    
          function ProfileDetails({ userId }) {
            // In a Suspense-enabled environment, this might "read" the data directly
            // rather than using traditional hooks with loading states.
            // For instance, if this were a Server Component, data fetching would suspend rendering.
            const user = use(fetchUserProfile(userId)); // Hypothetical 'use' hook for direct promise reading
    
            return (
              <div>
                <h2>{user.name}</h2>
                <p>{user.email}</p>
              </div>
            );
          }
    
          function App() {
            return (
              <Suspense fallback={<div>Loading profile...</div>}>
                <ProfileDetails userId={123} />
              </Suspense>
            );
          }

    Combining TanStack Query with Suspense

    The best of both worlds can be achieved by using TanStack Query's suspense option. This makes useQuery throw a promise when data is not ready, allowing it to be caught by a Suspense boundary.

          import { useQuery } from '@tanstack/react-query';
          import { Suspense } from 'react';
    
          async function fetchProduct(productId) {
            const res = await fetch(`/api/products/${productId}`);
            if (!res.ok) throw new Error('Failed to fetch product');
            return res.json();
          }
    
          function ProductDetails({ productId }) {
            const { data: product } = useQuery({
              queryKey: ['product', productId],
              queryFn: () => fetchProduct(productId),
              suspense: true, // Enable Suspense integration
            });
    
            return (
              <div>
                <h3>{product.name}</h3>
                <p>{product.description}</p>
                <p>Price: ${product.price}</p>
              </div>
            );
          }
    
          function ProductPage() {
            const productId = 42; // Example product ID
    
            return (
              <div>
                <h1>Product Page</h1>
                <Suspense fallback={<div>Loading product details...</div>}>
                  <ProductDetails productId={productId} />
                </Suspense>
              </div>
            );
          }

    This pattern offers a clean, declarative way to handle loading states at a higher level, improving user experience by coordinating multiple loading states.

    Best Practices for Modern Data Fetching

    1. Prefer TanStack Query for Server State: It handles virtually all complexities of server state management.

    2. Separate Server State from UI State: Use TanStack Query for data from APIs, and lightweight solutions like Zustand/Jotai or useState for client-only UI state.

    3. Embrace Suspense: As React's Concurrent Features mature, Suspense will become the primary way to orchestrate loading states.

    4. Consider Server Components: For data that doesn't need to be interactive on the client, fetching directly in Server Components is often the most performant approach.

    5. Error Boundaries: Always combine data fetching with React Error Boundaries to gracefully handle fetch errors.

    Conclusion

    The landscape of data fetching in React has matured significantly, moving towards more declarative and efficient patterns. By leveraging powerful libraries like TanStack Query for robust server state management and integrating with React Suspense for elegant loading experiences, you can build performant and maintainable applications that deliver a superior user experience. Understanding these modern approaches is crucial for any React developer aiming to build cutting-edge web applications.

    Tags

    React
    Data Fetching
    TanStack Query
    React Query
    Suspense
    Frontend
    Hooks