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



2. Conceptual Diagrams

100%
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:

JSX
const [data, setData] = useState(null)     // Success Data
const [loading, setLoading] = useState(true) // Loading...
const [error, setError] = useState(null)    // Error Message
▶ Try it Yourself

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:

TEXT 📖 Display only
Async data fetching with loading/error/success states
JSX
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">&#x26A0;</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:

TEXT 📖 Display only
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:

TEXT 📖 Display only
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
JSX
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>
  )
}
▶ Try it Yourself

Output:

TEXT 📖 Display only
Custom hook returns { data, loading, error }. Usage: const { data, loading } = useFetch("/api/users"). Auto-fetches on mount.

Benefits of Hooks:

  1. The component code has been significantly streamlined to focus on rendering logic.
  2. Unified maintenance of three-state logic; changes need only be made in one place
  3. The refetch function 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.

BASH
npm install axios

▶ Example 3: Wrapping an axios instance

Output:

TEXT 📖 Display only
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
JSX
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:

TEXT 📖 Display only
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:

TEXT 📖 Display only
... (`[API] ${config.method?.toUpperCase()
JSX
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:

TEXT 📖 Display only
Data fetching: ⏳ "Loading..." → ✅ data displayed → or ❌ "Error: Network request failed" with retry button

Key Mechanisms:

  1. Every time query changes, the useEffect cleanup function calls controller.abort() to cancel the previous request.
  2. Canceled requests enter the catch branch and are filtered out by err.name !== 'AbortError'—this prevents the error UI from being triggered by mistake.
  3. Ultimately, only the response to the last request will trigger setResults, completely eliminating the race condition.

▶ Example 5: Canceling an axios Request

Output:

TEXT 📖 Display only
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
JSX
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:

TEXT 📖 Display only
Axios: axios.get("/api/users") → response.data. Auto JSON parsing, interceptors, timeout. Cleaner than fetch.

▶ Example 6: Automatic Retry Strategy

Output:

TEXT 📖 Display only
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:

JSX
// 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)
▶ Try it Yourself

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

Q Which should I choose, fetch or axios?
A fetch is a built-in browser API that requires no dependencies and is suitable for simple requests. However, it requires manual handling of HTTP error status codes (4xx/5xx errors do not trigger the 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.
Q Why does three-state management use three separate useState hooks instead of a single object?
A Three separate 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.
Q Does the server still process the request after the AbortController aborts it?
A Yes. The AbortController only stops the front end from listening for the response; the server still receives and processes the request (it cannot prevent the request from reaching the server). For write operations (POST/PUT/DELETE), the backend must be designed to be idempotent, or the front end must implement measures to prevent duplicate submissions.
Q If the custom hook useFetch is called in multiple components, will they share state?
A No. Each time a component calls 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).
Q What should I keep in mind when handling 401 redirects to the login page uniformly in a response interceptor?
A Be careful to avoid an infinite redirect loop—if the login page itself makes an API request (to check the token’s validity) and that request also returns a 401, it will result in an infinite loop: “request → 401 → redirect → request → 401.” Solution: Check the current page path in the interceptor; if the user is already at /login, do not redirect them.

📖 Summary


📝 Exercises

  1. Create a user list component: Use a fetch request to retrieve data from https://jsonplaceholder.typicode.com/users and implement four UI states: loading (spinner), error (error message + retry button), no data (no data available), and normal rendering.
  2. 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.
  3. 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.
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏