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)
useEffectcontrols when side effects run- The dependency array determines re-execution conditions
| Dependency Array | When Effect Runs |
|---|---|
[] | Once on mount (component creation) |
[dep1, dep2] | On mount + whenever dep1 or dep2 changes |
| Omitted | After 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
finallyblock 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 Method | Purpose | Body Allowed |
|---|---|---|
| GET | Retrieve data | No |
| POST | Create new resource | Yes |
| PUT | Replace entire resource | Yes |
| PATCH | Update partial resource | Yes |
| DELETE | Remove resource | Optional |
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
cancelledflag prevents state updates after unmountrefetchfunction 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
- React Official Docs - Synchronizing with Effects: https://react.dev/learn/synchronizing-with-effects
- MDN Web Docs - Using Fetch: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
- TanStack Query (React Query) Documentation: https://tanstack.com/query/latest/docs/react/overview
π Quick Reference Card
| Basic Fetch | fetch(url).then(r => r.json()).then(data => ...) |
| Async/Await | const response = await fetch(url); |
| useEffect Mount | useEffect(() => { /* fetch */ }, []) |
| useEffect Reactive | useEffect(() => { /* fetch */ }, [id]) |
| Check Response | if (!response.ok) throw new Error(...) |
| POST Request | fetch(url, { method: 'POST', headers: {...}, body: JSON.stringify(data) }) |
| Axios GET | const response = await axios.get(url); |
| Abort Signal | const controller = new AbortController(); |
| Three States | const [data, setData] = useState(null); |