React: HTTP Requests and Data Retrieval
Last updated: 2026-08-26
When Tom called the API to retrieve a list of users on the user management page, three issues arose: First, rapidly switching between pages caused the resposta from the previous requisição to overwrite the data for the subsequent requisição (a race condition); second, when a network erro occurred, the page simply went blank without displaying any erro message; and finally, every page had to implement the same three-state logic—loading, erro, and data—repeatedly. He realized that a unified HTTP requisição solution was needed to manage the entire requisição lifecycle.
1. What You'll Learn
- Criteria for Choosing Between the Fetch API and Axios
- Best Practices for Managing the "Loading," "Error," and "Data" States
- AbortController: Cancels requests to prevent race conditions
- Axios instância encapsulation and interceptor configuration
- Error Handling and Automatic Retry Strategies
2. Conceptual Diagrams
flowchart LR
A[Submit a Request] --> B{loading = true}
B --> C[Request in progress]
C --> D{Success/Failure?}
D -->|Success| E[data = Response<br/>loading = false<br/>error = null]
D -->|Failure| F[error = Error Message<br/>loading = false<br/>data = null]
E --> G[Rendering Data]
F --> H{Can I try again??}
H -->|is | A
H -->|No| I[Display Error UI]
G --> J[Component Uninstallation?]
J -->|is | K[AbortController<br/>Cancel Request]
style B fill:#fff3e0,stroke:#f57c00
style D fill:#e1f5fe,stroke:#0288d1
style K fill:#ffcdd2,stroke:#d32f2f
Request lifecycle: loading begins → request is executed → data is assigned on success / error is assigned on failure → pending requests are canceled when the component is unmounted.
3. A Real-Life Scenario
Tom's user management page requires the following: the user list must load when the page is opened; a spinner must be displayed while the list is loading; an error message and a "Retry" button must appear if loading fails; data must not become disorganized when users quickly switch between the list and detail views; and all API requests must automatically redirect to the login page if a 401 error occurs.
(1) Three-State Mode
The most basic approach to making HTTP requests in React is to manage three state variables:
const [data, setData] = useState(null) // Success Data
const [loading, setLoading] = useState(true) // Loading...
const [error, setError] = useState(null) // Error Message
Why is a three-state separation necessary? Because the UI needs to display completely different content in three different situations:
| Status | Data | Loading | Error | UI Behavior |
|---|---|---|---|---|
| Loading | null | true | null | Show spinner or placeholder screen |
| Success | Data | false | null | Render data list |
| Failure | null | false | Error message | Display error message and retry button |
| Empty data | [] | false | null | Display "No data available" message |
▶ Example 1: Three-State Management with fetch and useEffect
Output:
Async data fetching with loading/error/success states
import { useState, useEffect } from 'react'
function UserList() {
const [users, setUsers] = useState([]) // Data
const [loading, setLoading] = useState(true) // Loaded state
const [error, setError] = useState(null) // Error State
function fetchUsers() {
setLoading(true)
setError(null)
fetch('https://jsonplaceholder.typicode.com/users')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}:${response.statusText}`)
}
return response.json()
})
.then(data => {
setUsers(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}
useEffect(() => {
fetchUsers()
}, [])
// --- Three-State Rendering ---
if (loading) {
return (
<div className="loading-state">
<div className="spinner" />
<p>Loading user data...</p>
</div>
)
}
if (error) {
return (
<div className="error-state">
<p className="error-icon">⚠</p>
<p>Failed to load:{error}</p>
<button onClick={fetchUsers}>Retry</button>
</div>
)
}
if (users.length === 0) {
return (
<div className="empty-state">
<p>No user data available</p>
</div>
)
}
return (
<ul>
{users.map(user => (
<li key={user.id}>
<strong>{user.name}</strong> — {user.email}
</li>
))}
</ul>
)
}
Output:
Data fetching: ⏳ "Loading..." → ✅ data displayed → or ❌ "Error: Network request failed" with retry button
Important Note: fetch() only throws an exception when a network error occurs; HTTP 4xx/5xx status codes will not trigger the catch block. Therefore, you must manually check response.ok (or response.status) within then and proactively throw an error for non-2xx responses.
(2) Custom Hook Extraction
It’s clearly impractical to write the three-state logic repeatedly on every page. Tom extracted the three-state logic into a custom hook, which can be used in any component with just one line of code.
▶ Example 2: The useFetch Custom Hook
Output:
Displays: "Loading user data...". State: users (setter: setUsers), loading (setter: setLoading), error (setter: setError). Button: Retry. useEffect manages side effects. Async data fetching/loading states
import { useState, useEffect } from 'react'
// General Data Request Hook
function useFetch(fetchFn, deps = []) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
function execute() {
setLoading(true)
setError(null)
fetchFn()
.then(result => {
setData(result)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}
useEffect(() => {
execute()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
return { data, loading, error, refetch: execute }
}
// ====== Usage ======
function UserList() {
const { data: users, loading, error, refetch } = useFetch(
() => fetch('https://jsonplaceholder.typicode.com/users')
.then(r => { if (!r.ok) throw new Error('Request Failed'); return r.json() }),
[]
)
if (loading) return <p>Loading......</p>
if (error) return <p>Error:{error} <button onClick={refetch}>Retry</button></p>
return (
<ul>
{users?.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
)
}
Output:
Custom hook returns { data, loading, error }. Usage: const { data, loading } = useFetch("/api/users"). Auto-fetches on mount.
Benefits of Hooks:
- The component code has been significantly streamlined to focus on rendering logic.
- Unified maintenance of three-state logic; changes need only be made in one place
- The
refetchfunction is exposed to the component to facilitate manually triggering a re-request
4. Advanced axios Wrappers
| Feature | fetch | axios |
|---|---|---|
| Installation | Built into the browser | npm install axios |
| Response Parsing | Manual res.json() |
Auto-Convert to JSON |
| Request/Response Interception | No built-in | Interceptor interceptors |
| Timeout Settings | Must be used with the AbortController |
timeout options |
| Error Handling | HTTP 4xx/5xx Errors Do Not Throw Exceptions | HTTP Errors Are Automatically Thrown |
| Request Cancellation | AbortController |
CancelToken (Old) / AbortController (New) |
| TypeScript | Manual type assertion required | Generics axios.get<T>() |
As the project grew, Tom found that he had to manually add tokens, handle 401 redirects, and set timeouts for every request—which was extremely tedious. Axios’s instantiation and interceptor mechanisms can solve all these problems at once.
npm install axios
▶ Example 3: Wrapping an axios instance
Output:
Displays: "Loading......". State: data (setter: setData), loading (setter: setLoading), error (setter: setError). Button: Retry. List: {u.name}. useEffect manages side effects. Async data fetching/loading states
import axios from 'axios'
// Get token(from localStorage or auth store)
function getToken() {
return localStorage.getItem('auth_token')
}
// Create axios Examples
const api = axios.create({
baseURL: '/api/v1', // Basic Path
timeout: 10000, // Timeout (10s)
headers: {
'Content-Type': 'application/json',
}
})
// ========== Request Interceptor ==========
api.interceptors.request.use(
config => {
// Auto-add Authorization header
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// Request Logs(Development Environment)
if (process.env.NODE_ENV === 'development') {
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`, config.params || '')
}
return config
},
error => {
console.error('[API] Request configuration error:', error)
return Promise.reject(error)
}
)
// ========== Response Interceptors ==========
api.interceptors.response.use(
// Successful Response:Return directly data Field(Remove the outer packaging)
response => response.data,
// Failure Response:Unified Error Handling
error => {
if (error.response) {
// The server returned an error status code
const { status, data } = error.response
switch (status) {
case 401:
// Unauthorized → Clear token,Go to the Login Page
localStorage.removeItem('auth_token')
window.location.href = '/login'
break
case 403:
console.warn('[API] Access Denied')
break
case 404:
console.warn('[API] Resource does not exist')
break
case 500:
console.error('[API] Internal Server Error')
break
default:
console.error(`[API] HTTP ${status}:`, data?.message || 'Unknown error')
}
return Promise.reject(new Error(data?.message || `HTTP ${status}`))
}
if (error.code === 'ECONNABORTED') {
// Request timed out
return Promise.reject(new Error('Request timed out,Please check your internet connection.'))
}
// Network error (offline, DNS failure, etc.)
return Promise.reject(new Error('Network Connection Error'))
}
)
// ========== Export the packaged API Methods ==========
export const userApi = {
getList: (params) => api.get('/users', { params }),
getById: (id) => api.get(`/users/${id}`),
create: (data) => api.post('/users', data),
update: (id, data) => api.put(`/users/${id}`, data),
delete: (id) => api.delete(`/users/${id}`),
}
export const productApi = {
getList: (params) => api.get('/products', { params }),
getById: (id) => api.get(`/products/${id}`),
}
// ========== Used in components ==========
function UserTable() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
userApi.getList({ page: 1, limit: 20 })
.then(data => {
setUsers(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [])
// ... Rendering Logic
}
Output:
State: query, results, loading. uses useeffect
The Power of Interceptors: Request interceptors automatically inject tokens, and response interceptors automatically handle 401 redirects—components and API callers don’t need to worry about these cross-cutting concerns at all.
5. Request Cancellation and Race Conditions
| Competitive Scenarios | Causes | Solutions |
|---|---|---|
| Search Input Race Condition | Rapid input triggers multiple requests; old responses overwrite new ones | AbortController cancels old requests |
| Page-Switching Race Condition | Responses to Old Requests Still Arrive After Page Switch | useEffect Cleanup Cancellation |
| Repeated button clicks | User clicks the submit button multiple times | Disable button + Cancel during request |
| Tab-Switching Race Condition | Quickly Switching Tabs Causes Data Misalignment | Use the ignore flag to ignore old responses |
This is the most subtle pitfall Tom has ever encountered. When a user quickly searches in an input field—typing "a" → "ab" → "abc" → "abcd"—if network speeds vary, the following may happen: the response for "abcd" arrives first, followed by the response for "a" (because the previous request wasn't canceled). As a result, the page displays the result for "a" instead of the latest result, "abcd."
This phenomenon is called a race condition. Solution: Before initiating a new request, cancel the previous uncompleted request.
▶ Example 4: AbortController—Canceling a Request
Output:
... (`[API] ${config.method?.toUpperCase()
import { useState, useEffect } from 'react'
function SearchUsers() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!query.trim()) {
setResults([])
return
}
// Create AbortController
const controller = new AbortController()
const signal = controller.signal
setLoading(true)
fetch(`/api/users/search?q=${encodeURIComponent(query)}`, { signal })
.then(res => res.json())
.then(data => {
setResults(data)
setLoading(false)
})
.catch(err => {
// Handle only non-canceled errors
if (err.name !== 'AbortError') {
console.error('Search Failed:', err)
setLoading(false)
}
})
// Cleanup Function:Component uninstallation or query Cancel the request when changes occur
return () => {
controller.abort()
}
}, [query])
return (
<div>
<input
placeholder="Search Users..."
value={query}
onChange={e => setQuery(e.target.value)}
/>
{loading && <p>Searching......</p>}
<ul>
{results.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
)
}
Output:
Data fetching: ⏳ "Loading..." → ✅ data displayed → or ❌ "Error: Network request failed" with retry button
Key Mechanisms:
- Every time
querychanges, the useEffect cleanup function callscontroller.abort()to cancel the previous request. - Canceled requests enter the catch branch and are filtered out by
err.name !== 'AbortError'—this prevents the error UI from being triggered by mistake. - Ultimately, only the response to the last request will trigger
setResults, completely eliminating the race condition.
▶ Example 5: Canceling an axios Request
Output:
Displays: "Searching......". State: query (setter: setQuery), results (setter: setResults), loading (setter: setLoading). Input: Search Users.... List: {user.name}. useEffect manages side effects. Async data fetching/loading states
import { useState, useEffect } from 'react'
import axios from 'axios'
function SearchProducts() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!query.trim()) {
setResults([])
return
}
// axios Cancel Token
const source = axios.CancelToken.source()
setLoading(true)
axios.get('/api/products/search', {
params: { q: query },
cancelToken: source.token
})
.then(res => {
setResults(res.data)
setLoading(false)
})
.catch(err => {
if (!axios.isCancel(err)) {
console.error('Search Failed:', err)
setLoading(false)
}
// Cancelled requests are not processed
})
return () => {
source.cancel('The request has been canceled') // Reason for Cancellation
}
}, [query])
return (
<div>
<input
placeholder="Search for Products..."
value={query}
onChange={e => setQuery(e.target.value)}
/>
{loading && <p>Searching......</p>}
<ul>
{results.map(p => (
<li key={p.id}>{p.name} — ${p.price}</li>
))}
</ul>
</div>
)
}
Output:
Axios: axios.get("/api/users") → response.data. Auto JSON parsing, interceptors, timeout. Cleaner than fetch.
▶ Example 6: Automatic Retry Strategy
Output:
Displays: "Searching......". State: query (setter: setQuery), results (setter: setResults), loading (setter: setLoading). Input: Search for Products.... List: {p.name} — ${p.price}. useEffect manages side effects
Since network requests are unreliable, Tom wants them to automatically retry when they fail (for example, retry twice with gradually increasing intervals). Axios does not have a built-in retry feature, but it can be easily implemented using an interceptor:
// Retry Interceptor
function setupRetryInterceptor(axiosInstance, maxRetries = 2) {
axiosInstance.interceptors.response.use(
response => response,
async error => {
const config = error.config
// Cases Where Retry Is Not Performed:Not configured、I've already tried again、It's not a network error
if (!config || config._retryCount >= maxRetries) {
return Promise.reject(error)
}
// Only in the event of a network error or 5xx Retry in Case of a Server Error
const status = error.response?.status
if (status && status < 500) {
return Promise.reject(error)
}
config._retryCount = (config._retryCount || 0) + 1
// Exponential backoff: 1st retry after 1s, 2nd retry after 2s
const delay = config._retryCount * 1000
await new Promise(r => setTimeout(r, delay))
console.log(`[API] Retry ${config._retryCount}/${maxRetries}: ${config.url}`)
return axiosInstance(config)
}
)
}
// When using
setupRetryInterceptor(api, 2)
Exponential Backoff is a standard retry strategy: the wait time increases with each retry to avoid continuing to put pressure on the server when it is already overloaded.
❓ FAQ
catch block) and does not support request progress monitoring. axios automatically parses JSON, supports request/response interceptors, makes request cancellation easier, and supports upload progress. Recommendation: Use fetch for small projects and axios for large projects.useState hooks instead of a single object?useState hooks allow components to precisely subscribe to changes in a specific portion of the state. If you use a single { data, loading, error } object, any change to any field will cause components subscribed to that object to re-render. However, in actual development, there isn’t much difference between the two approaches, so just choose whichever one you’re more comfortable with.useFetch is called in multiple components, will they share state?useFetch(), it creates an independent scope (closure), so the respective data, loading, and error values do not interfere with one another. If you need to share request data across components (such as when two components both display a user list), you’ll need global state management + TanStack Query (see the next lesson)./login, do not redirect them.📖 Summary
- Three-state management (loading / error / data) is the cornerstone of React data requests; these four UI states cover all scenarios.
- Use custom hooks to extract three-state logic, avoiding duplicate code in each component and enabling centralized maintenance
- Axios instance + interceptors to implement automatic token injection, automatic 401 redirects, and unified error handling
- AbortController (fetch) / CancelToken (axios) cancel pending requests, completely resolving race conditions
- Request cancellation is required for high-frequency change scenarios such as searching, page navigation, and tab switching.
📝 Exercises
- Create a user list component: Use a fetch request to retrieve data from
https://jsonplaceholder.typicode.com/usersand implement four UI states: loading (spinner), error (error message + retry button), no data (no data available), and normal rendering. - Based on the above assignment, extract the three-state logic into a custom hook
useFetch, then use it in two different components to verify whether the states are independent. - Create a search component: Have the input field search based on user input (simulating a 500 ms API delay), use the AbortController to cancel the previous uncompleted request, and verify that data does not become garbled when typing quickly.