You are viewing a preview of this lesson. Sign in to start learning
Back to React

Data Fetching & APIs

Communicate with APIs using modern data fetching tools

Data Fetching & APIs in React

Mastering data fetching and API integration is essential for building dynamic React applications, and this lesson provides free flashcards and practice exercises to help you retain these critical concepts. We'll explore modern fetching patterns including fetch(), useEffect() for lifecycle management, async/await syntax, error handling strategies, and popular libraries like Axios. You'll learn how to connect your React components to REST APIs, manage loading states, handle errors gracefully, and structure your code for maintainability.

Welcome πŸ’»

Welcome to one of the most crucial topics in modern React development! Nearly every real-world application needs to communicate with external servicesβ€”whether fetching user data, posting form submissions, or integrating with third-party APIs. Understanding how to properly fetch data in React will transform your static components into dynamic, data-driven interfaces.

In this lesson, we'll start with the basics of the Fetch API, progress through React's useEffect hook for managing side effects, explore async/await patterns for cleaner asynchronous code, and discuss production-ready patterns for error handling and loading states. By the end, you'll be equipped to build robust data-fetching solutions that follow React best practices.

Core Concepts 🎯

1. The Fetch API and Promises

The Fetch API is the modern browser standard for making HTTP requests. It returns a Promise that resolves to a Response object:

fetch('https://api.example.com/users')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));

Key characteristics:

  • Returns a Promise (asynchronous)
  • Requires two steps: get response, then parse it (.json(), .text(), etc.)
  • Only rejects on network failure, not HTTP errors (404, 500)
  • Native to modern browsers (no installation needed)

πŸ’‘ Tip: Always check response.ok before parsing, as fetch doesn't reject on HTTP error status codes!

2. The useEffect Hook for Data Fetching

In React, side effects (like API calls) belong in the useEffect hook, not in the component body:

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    fetch('https://api.example.com/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      });
  }, []); // Empty dependency array = run once on mount

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

Why useEffect?

  • Component body runs on every render (would create infinite loops)
  • useEffect controls when side effects run
  • The dependency array determines re-execution conditions
Dependency ArrayWhen Effect Runs
[]Once on mount (component creation)
[dep1, dep2]On mount + whenever dep1 or dep2 changes
OmittedAfter every render (usually not desired)

3. Async/Await Pattern

The async/await syntax makes asynchronous code look synchronous and is easier to read:

useEffect(() => {
  async function fetchUsers() {
    try {
      const response = await fetch('https://api.example.com/users');
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      const data = await response.json();
      setUsers(data);
    } catch (error) {
      console.error('Failed to fetch:', error);
      setError(error.message);
    } finally {
      setLoading(false);
    }
  }
  
  fetchUsers();
}, []);

Why this pattern?

  • More readable than chained .then() calls
  • Natural try/catch error handling
  • finally block runs regardless of success/failure

⚠️ Important: You can't make the useEffect callback itself async directly. Create an async function inside and call it!

4. Managing Loading, Error, and Data States

Professional React apps manage three states for data fetching:

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

  useEffect(() => {
    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch('https://api.example.com/data');
        if (!response.ok) throw new Error('Failed to fetch');
        const result = await response.json();
        setData(result);
        setError(null);
      } catch (err) {
        setError(err.message);
        setData(null);
      } finally {
        setLoading(false);
      }
    }
    fetchData();
  }, []);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;
  if (!data) return <EmptyState />;
  return <DataDisplay data={data} />;
}
DATA FETCHING STATE MACHINE

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚   INITIAL   β”‚
    β”‚ loading=trueβ”‚
    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           ↓
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚   FETCHING   β”‚
    β”‚      ...     β”‚
    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
      β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”
      ↓         ↓
  βœ… SUCCESS  ❌ ERROR
  β”‚ data set β”‚ error set β”‚
  β”‚ loading  β”‚ loading   β”‚
  β”‚ = false  β”‚ = false   β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

5. POST Requests and Request Configuration

Sending data requires configuring the request options:

async function createUser(userData) {
  try {
    const response = await fetch('https://api.example.com/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`
      },
      body: JSON.stringify(userData)
    });
    
    if (!response.ok) throw new Error('Failed to create user');
    return await response.json();
  } catch (error) {
    console.error('Error:', error);
    throw error;
  }
}
HTTP MethodPurposeBody Allowed
GETRetrieve dataNo
POSTCreate new resourceYes
PUTReplace entire resourceYes
PATCHUpdate partial resourceYes
DELETERemove resourceOptional

6. Using Axios Library

Axios is a popular alternative that provides conveniences over fetch:

import axios from 'axios';

useEffect(() => {
  async function fetchUsers() {
    try {
      const response = await axios.get('https://api.example.com/users');
      setUsers(response.data); // Axios auto-parses JSON
    } catch (error) {
      setError(error.message);
    }
  }
  fetchUsers();
}, []);

// POST with Axios
async function createUser(userData) {
  const response = await axios.post('https://api.example.com/users', userData);
  return response.data;
}

Axios advantages:

  • Automatic JSON parsing
  • Rejects promise on HTTP errors (4xx, 5xx)
  • Request/response interceptors
  • Automatic request cancellation
  • Better error messages

7. Cleanup and Cancellation

When components unmount during a fetch, you should cancel the request to avoid memory leaks:

useEffect(() => {
  const controller = new AbortController();
  
  async function fetchData() {
    try {
      const response = await fetch('https://api.example.com/data', {
        signal: controller.signal
      });
      const data = await response.json();
      setData(data);
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('Fetch aborted');
      } else {
        setError(error.message);
      }
    }
  }
  
  fetchData();
  
  // Cleanup function
  return () => controller.abort();
}, []);

πŸ’‘ Remember: useEffect can return a cleanup function that runs when the component unmounts or before the effect re-runs.

8. Custom Hooks for Reusability

Extract fetching logic into custom hooks to avoid repetition:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchData() {
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error('Fetch failed');
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    fetchData();
  }, [url]);

  return { data, loading, error };
}

// Usage
function UserList() {
  const { data: users, loading, error } = useFetch('https://api.example.com/users');
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

Examples πŸ“š

Example 1: Basic GET Request with Error Handling

This example demonstrates a complete pattern for fetching and displaying data:

import { useState, useEffect } from 'react';

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function loadProducts() {
      try {
        const response = await fetch('https://api.store.com/products');
        
        // Check if response is OK
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        
        const data = await response.json();
        setProducts(data);
        setError(null);
      } catch (err) {
        setError(err.message);
        setProducts([]);
      } finally {
        setLoading(false);
      }
    }
    
    loadProducts();
  }, []); // Run once on mount

  // Conditional rendering based on state
  if (loading) {
    return <div className="spinner">Loading products...</div>;
  }
  
  if (error) {
    return (
      <div className="error">
        <h3>Failed to load products</h3>
        <p>{error}</p>
      </div>
    );
  }
  
  return (
    <div className="product-list">
      {products.map(product => (
        <div key={product.id} className="product-card">
          <h3>{product.name}</h3>
          <p>${product.price}</p>
        </div>
      ))}
    </div>
  );
}

Key points:

  • Three-state management (loading, error, data)
  • Proper error checking with response.ok
  • Cleanup of previous state on error
  • Conditional rendering for user feedback

Example 2: Dependent Fetches (Fetching Based on User Selection)

Often you need to fetch data based on user input or state changes:

import { useState, useEffect } from 'react';

function UserPosts() {
  const [userId, setUserId] = useState(1);
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // Skip if no user selected
    if (!userId) return;
    
    async function fetchUserPosts() {
      setLoading(true);
      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/posts?userId=${userId}`
        );
        const data = await response.json();
        setPosts(data);
      } catch (error) {
        console.error('Failed to fetch posts:', error);
      } finally {
        setLoading(false);
      }
    }
    
    fetchUserPosts();
  }, [userId]); // Re-run when userId changes

  return (
    <div>
      <select value={userId} onChange={(e) => setUserId(Number(e.target.value))}>
        <option value={1}>User 1</option>
        <option value={2}>User 2</option>
        <option value={3}>User 3</option>
      </select>
      
      {loading ? (
        <p>Loading posts...</p>
      ) : (
        <ul>
          {posts.map(post => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

Key points:

  • Dependency array [userId] triggers re-fetch on changes
  • Guard clause if (!userId) return; prevents unnecessary fetches
  • Query parameters appended to URL
  • Loading state updates on each fetch

Example 3: POST Request (Form Submission)

Submitting data to an API:

import { useState } from 'react';

function CreateUser() {
  const [formData, setFormData] = useState({ name: '', email: '' });
  const [submitting, setSubmitting] = useState(false);
  const [result, setResult] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();
    setSubmitting(true);
    setResult(null);
    
    try {
      const response = await fetch('https://api.example.com/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(formData)
      });
      
      if (!response.ok) {
        throw new Error('Failed to create user');
      }
      
      const newUser = await response.json();
      setResult({ success: true, data: newUser });
      setFormData({ name: '', email: '' }); // Reset form
    } catch (error) {
      setResult({ success: false, error: error.message });
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Name"
        value={formData.name}
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
      />
      <input
        type="email"
        placeholder="Email"
        value={formData.email}
        onChange={(e) => setFormData({ ...formData, email: e.target.value })}
      />
      <button type="submit" disabled={submitting}>
        {submitting ? 'Creating...' : 'Create User'}
      </button>
      
      {result && (
        <div className={result.success ? 'success' : 'error'}>
          {result.success 
            ? `User created: ${result.data.name}` 
            : `Error: ${result.error}`
          }
        </div>
      )}
    </form>
  );
}

Key points:

  • POST method with headers and body
  • JSON.stringify() converts object to JSON string
  • Disabled button during submission
  • Form reset on success
  • User feedback for success/error states

Example 4: Custom Hook with Axios

Building a reusable data fetching hook:

import { useState, useEffect } from 'react';
import axios from 'axios';

function useApi(url, dependencies = []) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [refetchIndex, setRefetchIndex] = useState(0);

  useEffect(() => {
    let cancelled = false;
    
    async function fetchData() {
      setLoading(true);
      try {
        const response = await axios.get(url);
        if (!cancelled) {
          setData(response.data);
          setError(null);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }
    
    fetchData();
    
    return () => {
      cancelled = true; // Prevent state updates after unmount
    };
  }, [url, refetchIndex, ...dependencies]);
  
  const refetch = () => setRefetchIndex(prev => prev + 1);

  return { data, loading, error, refetch };
}

// Usage
function WeatherDisplay({ city }) {
  const { data: weather, loading, error, refetch } = useApi(
    `https://api.weather.com/current?city=${city}`,
    [city]
  );

  if (loading) return <div>Loading weather...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <div>
      <h2>Weather in {city}</h2>
      <p>Temperature: {weather.temp}Β°C</p>
      <p>Conditions: {weather.conditions}</p>
      <button onClick={refetch}>Refresh</button>
    </div>
  );
}

Key points:

  • Reusable hook encapsulates fetching logic
  • cancelled flag prevents state updates after unmount
  • refetch function allows manual re-fetching
  • Dynamic dependencies passed to useEffect

Common Mistakes ⚠️

1. Forgetting the Dependency Array

❌ Wrong:

useEffect(() => {
  fetch('/api/data').then(/*...*/);
}); // Runs after EVERY render = infinite loop if it updates state!

βœ… Correct:

useEffect(() => {
  fetch('/api/data').then(/*...*/);
}, []); // Empty array = run once on mount

2. Not Checking response.ok

❌ Wrong:

const response = await fetch(url);
const data = await response.json(); // Will parse error HTML as JSON!

βœ… Correct:

const response = await fetch(url);
if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();

3. Making useEffect Callback Async Directly

❌ Wrong:

useEffect(async () => {
  const data = await fetch(url); // useEffect must return cleanup function or nothing!
}, []);

βœ… Correct:

useEffect(() => {
  async function fetchData() {
    const data = await fetch(url);
  }
  fetchData();
}, []);

4. Not Handling Loading and Error States

❌ Wrong:

function Component() {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch(url).then(r => r.json()).then(setData);
  }, []);
  return <div>{data.name}</div>; // Crashes if data is null!
}

βœ… Correct:

function Component() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  if (loading) return <div>Loading...</div>;
  if (!data) return <div>No data</div>;
  return <div>{data.name}</div>;
}

5. Forgetting to Cancel Requests on Unmount

❌ Wrong:

useEffect(() => {
  fetch(url).then(r => r.json()).then(setData);
}, []); // Component unmounts = state update on unmounted component!

βœ… Correct:

useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal })
    .then(r => r.json())
    .then(setData);
  return () => controller.abort();
}, []);

6. Mutating State Directly

❌ Wrong:

const response = await fetch(url);
data.push(response.data); // Mutates state directly!
setData(data);

βœ… Correct:

const response = await fetch(url);
setData([...data, response.data]); // Creates new array

Key Takeaways 🎯

βœ… Use useEffect for side effects like API callsβ€”never fetch in the component body

βœ… Manage three states: loading, error, and data for robust UIs

βœ… Always check response.ok before parsingβ€”fetch doesn't reject on HTTP errors

βœ… Use async/await with try/catch for cleaner, more readable asynchronous code

βœ… Include cleanup functions to cancel requests when components unmount

βœ… Create custom hooks to extract and reuse fetching logic across components

βœ… Consider Axios for conveniences like automatic JSON parsing and better error handling

βœ… Use dependency arrays correctlyβ€”empty [] for mount-only, [var] for reactive fetches

πŸ€” Did you know? The Fetch API was designed to replace the older XMLHttpRequest (XHR) API, which had a cumbersome syntax and was harder to work with. Modern frameworks like React work beautifully with Promises and async/await!

πŸ“š Further Study

  1. React Official Docs - Synchronizing with Effects: https://react.dev/learn/synchronizing-with-effects
  2. MDN Web Docs - Using Fetch: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
  3. TanStack Query (React Query) Documentation: https://tanstack.com/query/latest/docs/react/overview

πŸ“‹ Quick Reference Card

Basic Fetchfetch(url).then(r => r.json()).then(data => ...)
Async/Awaitconst response = await fetch(url);
const data = await response.json();
useEffect MountuseEffect(() => { /* fetch */ }, [])
useEffect ReactiveuseEffect(() => { /* fetch */ }, [id])
Check Responseif (!response.ok) throw new Error(...)
POST Requestfetch(url, { method: 'POST', headers: {...}, body: JSON.stringify(data) })
Axios GETconst response = await axios.get(url);
const data = response.data;
Abort Signalconst controller = new AbortController();
fetch(url, { signal: controller.signal })
return () => controller.abort();
Three Statesconst [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);