Updated 11 days ago | GitHub

React Data Fetching From an API

Overview

Data fetching is a fundamental part of modern web applications. We use it when we need to log in, find data from an API or even update or delete data. This guide covers different approaches to fetching data in React applications.

This guide covers:

  • Different methods of fetching data (fetch, async/await, axios)
  • Best practices for handling loading and error states
  • Managing API calls triggered by user interactions
  • Popular data-fetching libraries

Data Fetching Flow

Here’s a visual representation of the basic data fetching flow in React. We will use this flow to understand the different approaches to fetching data.

Diagram: Data Fetching Flow

Methods of Data Fetching

There are multiple ways to fetch data in React. Let’s look at the most common ones:

1. Using fetch with useEffect

The fetch API is built into modern browsers and provides a simple way to make HTTP requests. It returns a promise that resolves to a Response object. This is a low-level API and requires manual parsing of the response body with .json(), but it is an effective way to fetch data.

function UserList() {
  const [users, setUsers] = useState([]);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Set to true on cleanup so a response that arrives after unmount is ignored
    let ignore = false;

    fetch('https://api.example.com/users')
      // fetch call returns a promise, so we need to chain .then() to handle the response
      .then(response => {
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        return response.json(); 
      })
      // Response is a promise, so we need to chain .then() to handle the response
      .then(data => {
        // data is the JSON body of the response
        if (ignore) return; // Stale response after cleanup; don't set state
        setUsers(data);
        setLoading(false);
      })
      .catch(error => {
        // error is an object that contains the error message
        if (ignore) return;
        setError(error.message);
        setLoading(false);
      });

    // Cleanup runs on unmount, flipping the flag so in-flight responses are discarded
    return () => {
      ignore = true;
    };
  }, []); // Empty dependency array means fetch only runs once on mount

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

2. Using async/await with useEffect

async/await provides a more readable way to handle promises by allowing us to write asynchronous code that looks synchronous. This approach is particularly useful for complex data fetching scenarios where you need to handle multiple steps or dependencies.

Key benefits of using async/await:

  • More readable and maintainable code
  • Simpler error handling with try/catch blocks
  • Easier to debug with step-by-step execution
  • Simpler to handle multiple sequential async operations
function UserProfile({ userId }) {
  // Initialize state for user data, loading status, and potential errors
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Set to true on cleanup so a response that arrives after unmount
    // (or after userId changes) doesn't update state
    let ignore = false;

    // We need to create a separate async function because useEffect's callback
    // cannot be directly async (React needs synchronous cleanup)
    const fetchUser = async () => {
      try {
        // Set loading state before starting the fetch
        setLoading(true);

        // Await the fetch call - this pauses execution until the request completes
        const response = await fetch(`https://api.example.com/users/${userId}`);
        
        // Check if the response is ok (status in the range 200-299)
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }

        // Await the JSON parsing - this also returns a promise
        const data = await response.json();
        
        // Update the user state with the fetched data
        if (!ignore) setUser(data);
      } catch (err) {
        // If any error occurs in the try block, it will be caught here
        if (!ignore) setError(err.message);
      } finally {
        // This block always runs, regardless of success or failure
        if (!ignore) setLoading(false);
      }
    };

    // Call the async function immediately
    fetchUser();

    // Cleanup runs before the effect re-runs and on unmount, so the
    // in-flight request's result is ignored once it's out of date
    return () => {
      ignore = true;
    };

    // The dependency array includes userId so the effect runs when userId changes
  }, [userId]);

  // Show appropriate UI based on the current state
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>No user found</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

🎥 Video: Javascript Promises vs Async Await EXPLAINED (in 5 minutes)

3. Using axios with useEffect

Axios is a popular HTTP client that offers several advantages over the native fetch API. It provides a more powerful and flexible way to make HTTP requests with features like automatic JSON transformation, request/response interceptors, and better error handling.

Key features of axios:

  • Automatic JSON data transformation
  • Built-in XSRF protection
  • Request and response interceptors
  • Request cancellation
  • Better error handling with detailed error objects
import axios from 'axios';

function PostList() {
  // Initialize state for posts, loading status, and errors
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Set to true on cleanup so a response that arrives after unmount is ignored
    let ignore = false;

    // Create an axios instance with default configuration
    // This is useful when you need to make multiple requests to the same API
    const api = axios.create({
      baseURL: 'https://api.example.com', // Base URL for all requests
      timeout: 5000, // Request timeout in milliseconds
      headers: {
        'Content-Type': 'application/json'
        // You can add authentication headers here
        // 'Authorization': `Bearer ${token}`
      }
    });

    // Optional: Setup request interceptor for authentication
    // Interceptors must be registered BEFORE any request is dispatched
    // so they apply to subsequent api.get / api.post / etc. calls.
    api.interceptors.request.use((config) => {
      // You can modify the request config here
      // For example, add authentication tokens
      return config;
    });

    // Optional: Setup response interceptor for error handling
    api.interceptors.response.use(
      response => response,
      error => {
        // Handle common errors here
        if (error.response?.status === 401) {
          // Handle unauthorized access
        }
        return Promise.reject(error);
      }
    );

    const fetchPosts = async () => {
      try {
        setLoading(true);
        
        // Axios automatically transforms JSON response
        // No need to call .json() like with fetch
        const response = await api.get('/posts');
        
        // Axios puts the response body in the .data property
        if (!ignore) setPosts(response.data);
      } catch (err) {
        // Axios provides detailed error information
        // err.response contains the response from the server
        // err.request contains the request that was made
        if (!ignore) setError(err.response?.data?.message || 'Failed to fetch posts');
      } finally {
        if (!ignore) setLoading(false);
      }
    };

    fetchPosts();

    // Cleanup runs on unmount, flipping the flag so in-flight responses are discarded
    return () => {
      ignore = true;
    };
  }, []); // Empty dependency array means this effect runs once on mount

  if (loading) return <div>Loading posts...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </article>
      ))}
    </div>
  );
}

🎥 Video: Fetch Data with Axios

Triggering API Calls on User Interaction

Sometimes you want to fetch data in response to user actions rather than automatically when a component mounts. This pattern is common in search interfaces, forms, and any interactive features that require fresh data.

Key considerations for user-triggered API calls:

  • Debouncing/throttling to prevent too many requests
  • Proper loading states for user feedback
  • Error handling and retry mechanisms
  • Input validation before making requests
function SearchUsers() {
  // State for the search query and results
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  // Handler for the search action
  const handleSearch = async () => {
    // Validate input before making the request
    if (!query.trim()) return;

    setLoading(true);
    setError(null); // Clear any previous errors

    try {
      // Encode the query parameter to handle special characters
      const response = await fetch(
        `https://api.example.com/search?q=${encodeURIComponent(query)}`
      );
      
      if (!response.ok) {
        throw new Error('Search failed');
      }

      const data = await response.json();
      setResults(data);
    } catch (err) {
      console.error('Search failed:', err);
      setError('Failed to perform search. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <div className="search-container">
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search users..."
          // Optional: Add keyboard support
          onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
        />
        <button 
          onClick={handleSearch}
          disabled={loading || !query.trim()}
        >
          {loading ? 'Searching...' : 'Search'}
        </button>
      </div>

      {error && <div className="error-message">{error}</div>}
      
      <div className="results-container">
        {results.map(user => (
          <div key={user.id} className="user-card">
            <h3>{user.name}</h3>
            <p>{user.email}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

Comparing Data Fetching Methods

Here’s a comparison of different data fetching approaches:

fetch

Pros:

  • Built into browsers
  • No additional dependencies
  • Simple for basic requests

Cons:

  • More verbose error handling
  • No request timeout by default
  • Requires two-step process for JSON

async/await

Pros:

  • Cleaner, more readable code
  • Better error handling with try/catch
  • Works well with other async operations

Cons:

  • Requires separate function in useEffect
  • Only handles promise-based APIs (callback APIs need to be promisified first)

axios

Pros:

  • Consistent API across browsers
  • Built-in request/response interceptors
  • Automatic JSON transformation
  • Better error handling
  • Request cancellation

Cons:

  • Additional dependency
  • Larger bundle size
  • Learning curve for advanced features

API Request Lifecycle

Here’s how different data fetching methods handle the request lifecycle:

Diagram: API Request Lifecycle

🎥 Video: Fetching APIs in React JS

Comparison of Methods

Here’s a visual comparison of the three main approaches:

Diagram: Comparison of Methods

🎥 Video: Axios vs Fetch

Best Practices

1. Handling Loading States

Always show loading indicators to improve user experience:

function LoadingExample() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  // ... fetching logic ...

  return (
    <div>
      {loading ? (
        <LoadingSpinner />
      ) : (
        <DataDisplay data={data} />
      )}
    </div>
  );
}

2. Error Handling

Implement proper error boundaries and error states:

function ErrorHandlingExample() {
  const [error, setError] = useState(null);

  if (error) {
    return (
      <ErrorDisplay
        message={error.message}
        onRetry={() => setError(null)}
      />
    );
  }

  // ... rest of component
}

3. Request Cancellation

Cancel pending requests when component unmounts:

function CancellableRequest() {
  useEffect(() => {
    const abortController = new AbortController();

    fetch('https://api.example.com/data', { signal: abortController.signal })
      .then(response => response.json())
      .then(data => {
        // Handle data
      })
      .catch(error => {
        if (error.name === 'AbortError') return; // expected on unmount
        // Handle other errors
      });

    return () => {
      abortController.abort();
    };
  }, []);
}

Error Handling Flow

Here’s how to implement comprehensive error handling:

Diagram: Error Handling Flow

🎥 Video: React Error Boundaries: How to Handle and Recover from Errors

Additional Resources