Fetch API in React: Complete Data Fetching Guide
When you start fetching data from a backend in React, you'll quickly learn that combining the browser's Fetch API with React's component lifecycle requires careful handling. The Fetch API is the modern standard for making HTTP requests, and when you integrate it properly with React's hooks, you unlock patterns that scale from simple API calls to complex data management scenarios.
This guide walks you through everything from basic data fetching to handling edge cases that you'll encounter in production applications. We'll cover the patterns that work, the gotchas that trip up developers, and the optimization techniques that keep your app performant.
Table of Contents
- What is Fetch API?
- Basic Data Fetching with useEffect
- Handling Errors and Loading States
- Cleanup and Race Conditions
- Posting Data with Fetch
- Type Safety with TypeScript
- Practical Real-World Example
- Advanced Patterns
- FAQ
What is Fetch API?
The Fetch API is a browser standard for making HTTP requests. It replaces the older XMLHttpRequest approach with a cleaner, promise-based interface. Here's what makes it powerful: it returns a Promise, works with async/await, and provides a straightforward way to handle responses.
The basic syntax is simple: fetch(url, options). The first argument is your endpoint, and the optional second argument contains request configuration like method, headers, and body.
One crucial thing to understand: Fetch doesn't reject on HTTP error status codes like 404 or 500. It only rejects on network failure. This means you need to check response.ok explicitly.
Basic Data Fetching with useEffect
The most common pattern is fetching data when your component mounts. You'll use useEffect to trigger the fetch operation and useState to store the response.
Here's the pattern:
TypeScript Version
import { useState, useEffect } from 'react';
interface Post {
id: number;
title: string;
body: string;
}
export function PostsList() {
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchPosts = async () => {
setIsLoading(true);
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await response.json() as Post[];
setPosts(data);
} catch (error) {
console.error('Failed to fetch posts:', error);
} finally {
setIsLoading(false);
}
};
fetchPosts();
}, []);
if (isLoading) {
return <div>Loading posts...</div>;
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
);
}
JavaScript Version
import { useState, useEffect } from 'react';
export function PostsList() {
const [posts, setPosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchPosts = async () => {
setIsLoading(true);
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await response.json();
setPosts(data);
} catch (error) {
console.error('Failed to fetch posts:', error);
} finally {
setIsLoading(false);
}
};
fetchPosts();
}, []);
if (isLoading) {
return <div>Loading posts...</div>;
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
);
}
Notice the pattern here: we define an async function inside useEffect and call it immediately. We can't make useEffect itself async because it can't handle the returned promise properly.
Handling Errors and Loading States
Real applications need proper error handling. You need to distinguish between loading, success, and error states. Here's a more robust approach:
TypeScript Version
import { useState, useEffect } from 'react';
interface Post {
id: number;
title: string;
body: string;
}
interface FetchState {
data: Post[] | null;
isLoading: boolean;
error: string | null;
}
export function PostsList() {
const [state, setState] = useState<FetchState>({
data: null,
isLoading: true,
error: null,
});
useEffect(() => {
const fetchPosts = async () => {
setState({ data: null, isLoading: true, error: null });
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json() as Post[];
setState({ data, isLoading: false, error: null });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setState({ data: null, isLoading: false, error: errorMessage });
}
};
fetchPosts();
}, []);
if (state.isLoading) {
return <div className="loading">Loading posts...</div>;
}
if (state.error) {
return <div className="error">Error: {state.error}</div>;
}
if (!state.data) {
return <div>No posts found</div>;
}
return (
<ul>
{state.data.map(post => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
);
}
JavaScript Version
import { useState, useEffect } from 'react';
export function PostsList() {
const [state, setState] = useState({
data: null,
isLoading: true,
error: null,
});
useEffect(() => {
const fetchPosts = async () => {
setState({ data: null, isLoading: true, error: null });
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setState({ data, isLoading: false, error: null });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setState({ data: null, isLoading: false, error: errorMessage });
}
};
fetchPosts();
}, []);
if (state.isLoading) {
return <div className="loading">Loading posts...</div>;
}
if (state.error) {
return <div className="error">Error: {state.error}</div>;
}
if (!state.data) {
return <div>No posts found</div>;
}
return (
<ul>
{state.data.map(post => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
);
}
The key improvements here: we check response.ok before assuming success, we properly handle errors with type-safe error messages, and we track loading state separately from data state.
Cleanup and Race Conditions
Here's where things get tricky. If your component unmounts while a fetch is in progress, you'll get a memory leak warning. Additionally, if users trigger multiple requests quickly, earlier responses might arrive after newer ones, causing stale data to overwrite fresh data.
TypeScript Version
import { useState, useEffect } from 'react';
interface Post {
id: number;
title: string;
body: string;
}
export function PostsList() {
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Track whether component is still mounted
let isMounted = true;
let controller: AbortController | null = null;
const fetchPosts = async () => {
setIsLoading(true);
controller = new AbortController();
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts',
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json() as Post[];
// Only update state if component is still mounted
if (isMounted) {
setPosts(data);
}
} catch (error) {
// AbortError is expected when request is cancelled
if (error instanceof Error && error.name === 'AbortError') {
return;
}
console.error('Failed to fetch posts:', error);
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
fetchPosts();
// Cleanup function
return () => {
isMounted = false;
controller?.abort();
};
}, []);
if (isLoading) {
return <div>Loading posts...</div>;
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
);
}
JavaScript Version
import { useState, useEffect } from 'react';
export function PostsList() {
const [posts, setPosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Track whether component is still mounted
let isMounted = true;
let controller = null;
const fetchPosts = async () => {
setIsLoading(true);
controller = new AbortController();
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts',
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Only update state if component is still mounted
if (isMounted) {
setPosts(data);
}
} catch (error) {
// AbortError is expected when request is cancelled
if (error instanceof Error && error.name === 'AbortError') {
return;
}
console.error('Failed to fetch posts:', error);
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
fetchPosts();
// Cleanup function
return () => {
isMounted = false;
controller?.abort();
};
}, []);
if (isLoading) {
return <div>Loading posts...</div>;
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
);
}
This pattern uses AbortController to cancel in-flight requests when the component unmounts. This is the modern solution that handles race conditions properly.
Posting Data with Fetch
Sending data to a server requires configuring the fetch options. You'll specify the HTTP method, headers, and body:
TypeScript Version
import { useState } from 'react';
interface NewPost {
title: string;
body: string;
userId: number;
}
export function CreatePostForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
const formData = new FormData(e.currentTarget);
const newPost: NewPost = {
title: formData.get('title') as string,
body: formData.get('body') as string,
userId: 1,
};
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newPost),
}
);
if (!response.ok) {
throw new Error(`Failed to create post: ${response.status}`);
}
const createdPost = await response.json();
console.log('Post created:', createdPost);
// Reset form or redirect user
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="title"
placeholder="Post title"
required
/>
<textarea
name="body"
placeholder="Post content"
required
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Post'}
</button>
{error && <div className="error">{error}</div>}
</form>
);
}
JavaScript Version
import { useState } from 'react';
export function CreatePostForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
const formData = new FormData(e.currentTarget);
const newPost = {
title: formData.get('title'),
body: formData.get('body'),
userId: 1,
};
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newPost),
}
);
if (!response.ok) {
throw new Error(`Failed to create post: ${response.status}`);
}
const createdPost = await response.json();
console.log('Post created:', createdPost);
// Reset form or redirect user
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="title"
placeholder="Post title"
required
/>
<textarea
name="body"
placeholder="Post content"
required
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Post'}
</button>
{error && <div className="error">{error}</div>}
</form>
);
}
The critical points for POST requests: specify the correct HTTP method, set appropriate headers (usually Content-Type: application/json), and use JSON.stringify() to convert your data object to a JSON string.
Type Safety with TypeScript
When you're using TypeScript, you want to ensure your fetched data matches what you expect. A common approach is using type assertion functions to validate the response:
// Validate that response data matches expected shape
function assertIsPost(data: unknown): asserts data is Post {
if (typeof data !== 'object' || data === null) {
throw new Error('Post must be an object');
}
const obj = data as Record<string, unknown>;
if (typeof obj.id !== 'number') {
throw new Error('Post must have numeric id');
}
if (typeof obj.title !== 'string') {
throw new Error('Post must have string title');
}
if (typeof obj.body !== 'string') {
throw new Error('Post must have string body');
}
}
// Use in fetch
const response = await fetch(url);
const data = await response.json();
assertIsPost(data);
// Now TypeScript knows data is of type Post
This approach ensures type safety without relying on external validation libraries. The assertion function both validates at runtime and tells TypeScript the actual type.
Practical Real-World Example
Consider a common scenario in Chinese tech companies like ByteDance or Alibaba: you're building a content feed that users can filter and paginate. You need to handle concurrent requests, cancel previous requests when users change filters, and maintain cache efficiency.
TypeScript Version
import { useState, useEffect, useCallback } from 'react';
interface Article {
id: string;
title: string;
author: string;
timestamp: number;
}
interface ContentFeedProps {
category: string;
page: number;
}
export function ContentFeed({ category, page }: ContentFeedProps) {
const [articles, setArticles] = useState<Article[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
const fetchArticles = async () => {
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams({
category,
page: String(page),
limit: '20',
});
const response = await fetch(
`https://api.example.com/articles?${params}`,
{
signal: controller.signal,
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: Failed to fetch articles`);
}
const data = await response.json();
if (isMounted) {
setArticles(data.articles);
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
if (isMounted) {
setError(error instanceof Error ? error.message : 'Unknown error');
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
fetchArticles();
return () => {
isMounted = false;
controller.abort();
};
}, [category, page]);
if (isLoading) {
return <div className="feed-loading">Loading articles...</div>;
}
if (error) {
return <div className="feed-error">Failed to load: {error}</div>;
}
return (
<div className="feed">
{articles.map(article => (
<article key={article.id}>
<h3>{article.title}</h3>
<p>By {article.author}</p>
<time>{new Date(article.timestamp).toLocaleDateString()}</time>
</article>
))}
</div>
);
}
JavaScript Version
import { useState, useEffect } from 'react';
export function ContentFeed({ category, page }) {
const [articles, setArticles] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
const fetchArticles = async () => {
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams({
category,
page: String(page),
limit: '20',
});
const response = await fetch(
`https://api.example.com/articles?${params}`,
{
signal: controller.signal,
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: Failed to fetch articles`);
}
const data = await response.json();
if (isMounted) {
setArticles(data.articles);
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
if (isMounted) {
setError(error instanceof Error ? error.message : 'Unknown error');
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
fetchArticles();
return () => {
isMounted = false;
controller.abort();
};
}, [category, page]);
if (isLoading) {
return <div className="feed-loading">Loading articles...</div>;
}
if (error) {
return <div className="feed-error">Failed to load: {error}</div>;
}
return (
<div className="feed">
{articles.map(article => (
<article key={article.id}>
<h3>{article.title}</h3>
<p>By {article.author}</p>
<time>{new Date(article.timestamp).toLocaleDateString()}</time>
</article>
))}
</div>
);
}
This pattern handles the real-world scenario where users change filters or pagination. When category or page changes, the old request is aborted, and a new one starts. The isMounted flag prevents state updates after unmounting.
Advanced Patterns
Request Interceptors with Fetch
You can create a wrapper around fetch to handle authentication headers automatically:
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
...options.headers,
} as HeadersInit;
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, {
...options,
headers,
});
if (response.status === 401) {
// Handle token refresh or redirect to login
}
return response;
}
Batching and Debouncing
When users trigger multiple requests (like search), debounce the requests:
import { useEffect, useRef } from 'react';
export function useSearch(searchTerm: string, onResults: (results: any[]) => void) {
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (!searchTerm.trim()) {
return;
}
// Delay fetch until user stops typing
timeoutRef.current = setTimeout(async () => {
const response = await fetch(`/api/search?q=${searchTerm}`);
const data = await response.json();
onResults(data);
}, 300);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [searchTerm, onResults]);
}
FAQ
Q: Why can't I use async/await directly in useEffect?
A: useEffect expects its callback to either return undefined or a cleanup function. An async function always returns a Promise, which useEffect doesn't know how to handle. That's why you define an async function inside useEffect and call it immediately.
Q: How do I add authentication headers to fetch requests?
A: Pass headers in the second argument to fetch. For Bearer tokens, use: headers: { 'Authorization': 'Bearer your-token' }. Create a wrapper function to avoid repeating this for every request.
Q: What's the difference between checking response.ok and the response status?
A: response.ok is a boolean that's true for status codes 200-299. You can also check response.status directly. Fetch doesn't reject on HTTP error statuses, so you must check explicitly—otherwise a 404 appears as a "successful" response.
Q: Should I use Fetch API or a library like axios?
A: Fetch API is sufficient for most cases. Libraries like axios add features like request/response interceptors and automatic JSON transformation. For simpler projects, native fetch is fine. As your app grows, consider libraries that also manage caching (like React Query) rather than just HTTP.
Q: How do I handle timeouts with Fetch?
A: Fetch doesn't have built-in timeout, but you can combine it with AbortController: const controller = new AbortController(); setTimeout(() => controller.abort(), 5000); to abort after 5 seconds.
Have questions about data fetching in React? Share your specific use cases in the comments below. Whether you're handling large datasets, managing multiple concurrent requests, or optimizing for performance, the React community is here to help.
Google AdSense Placeholder
CONTENT SLOT