Next.js starter your AI actually understands. Ship internal tools in days not weeks. Pre-order $199 $499 → [Get it now]

How to use async/await in React data fetching

Fetching data asynchronously is a fundamental requirement for modern React applications, yet managing the lifecycle of these requests can be tricky. As a developer with over 25 years of experience and the creator of CoreUI, I have implemented countless data-driven interfaces using React since 2014. The most efficient and modern solution is to define an internal asynchronous function within the useEffect hook to handle the request lifecycle. This approach provides a clean syntax that avoids “callback hell” and integrates seamlessly with React’s state management.

Use an internal async function within a useEffect hook to fetch data and update your component state when the promise resolves.

import React, { useState, useEffect } from 'react'

const DataFetcher = () => {
  const [data, setData] = useState(null)

  useEffect(() => {
    // Define the async function inside the effect
    const fetchData = async () => {
      const response = await fetch('https://api.example.com/data')
      const result = await response.json()
      // Update state with the fetched result
      setData(result)
    }

    // Execute the async function
    fetchData()
  }, []) // Empty dependency array means this runs once on mount

  return (
    <div>
      {data ? <pre>{JSON.stringify(data, null, 2)}</pre> : 'Loading...'}
    </div>
  )
}

export default DataFetcher

In this example, we define fetchData as an async function inside the useEffect hook. We use the await keyword to pause execution until the fetch promise and the json() parsing promise resolve. Finally, we call fetchData() immediately. This pattern is necessary because the useEffect callback itself cannot be an async function, as React expects the effect to return either nothing or a cleanup function, not a Promise.

Best Practice Note:

Always define the async function inside the effect to keep your component logic encapsulated and avoid unnecessary re-renders. This is the same approach we use in CoreUI components to ensure reliable data synchronization between the UI and the backend.

Handling Loading and Error States

When performing real-world data fetching, it is critical to provide visual feedback to the user during the request and handle potential network failures gracefully.

import React, { useState, useEffect } from 'react'
import { CAlert, CSpinner } from '@coreui/react'
// Link to documentation: https://coreui.io/react/docs/components/spinner/
// Link to documentation: https://coreui.io/react/docs/components/alert/

const AdvancedFetcher = () => {
  const [items, setItems] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    const loadData = async () => {
      try {
        setLoading(true)
        const response = await fetch('https://api.example.com/items')
        
        if (!response.ok) {
          throw new Error('Network response was not ok')
        }

        const data = await response.json()
        setItems(data)
      } catch (err) {
        // Set error message to display to the user
        setError(err.message)
      } finally {
        // Stop the loading indicator regardless of success or failure
        setLoading(false)
      }
    }

    loadData()
  }, [])

  if (loading) return <CSpinner color='primary' />
  if (error) return <CAlert color='danger'>{error}</CAlert>

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  )
}

This pattern uses a try...catch...finally block to manage state transitions. The loading state ensures the user sees a CSpinner while the request is in flight. If an error occurs, the catch block captures it and updates the error state, which we display using a CAlert component. The finally block is perfect for resetting the loading state, ensuring the UI is updated correctly whether the request succeeded or failed. For a deeper dive into error handling patterns, see our guide on how to handle async/await errors in JavaScript.

Preventing Race Conditions with AbortController

In dynamic applications, a component might unmount or a new request might start before a previous one finishes, leading to “race conditions” or memory leaks.

import React, { useState, useEffect } from 'react'

const SearchResults = ({ query }) => {
  const [results, setResults] = useState([])

  useEffect(() => {
    // Create an AbortController for the current request
    const controller = new AbortController()
    const signal = controller.signal

    const performSearch = async () => {
      try {
        const response = await fetch(`https://api.example.com/search?q=${query}`, { signal })
        const data = await response.json()
        setResults(data)
      } catch (err) {
        if (err.name === 'AbortError') {
          // Ignore the error if the request was intentionally aborted
          console.log('Fetch aborted')
        } else {
          console.error('Fetch error:', err)
        }
      }
    }

    if (query) {
      performSearch()
    }

    // Cleanup function to abort the request if the component unmounts
    // or if the query changes before the previous fetch completes
    return () => {
      controller.abort()
    }
  }, [query]) // Re-run effect whenever query changes

  return (
    <div>
      <p>Results for: {query}</p>
      {results.length > 0 ? (
        <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>
      ) : (
        <p>No results found</p>
      )}
    </div>
  )
}

Using AbortController allows you to cancel the fetch request when it is no longer needed. This is particularly important for search inputs where the user types quickly. Before setting the state, check if the request was aborted to prevent updating the state of an unmounted component. You can also use how to get the length of an array in JavaScript to decide whether to show a “no results” message.

Fetching Data based on State Changes

Often, you need to fetch data based on a user selection, such as a category or a page number.

import React, { useState, useEffect } from 'react'
import { CSelect } from '@coreui/react'
// Link to documentation: https://coreui.io/react/docs/forms/select/

const CategoryViewer = () => {
  const [category, setCategory] = useState('tech')
  const [data, setData] = useState([])

  useEffect(() => {
    const getCategoryData = async () => {
      // Construct URL based on current state
      const url = `https://api.example.com/categories/${category}`
      const response = await fetch(url)
      const json = await response.json()
      
      setData(json)
    }

    getCategoryData()
  }, [category]) // This dependency ensures we refetch when category changes

  return (
    <div>
      <CSelect 
        value={category} 
        onChange={(e) => setCategory(e.target.value)}
        options={[
          { label: 'Technology', value: 'tech' },
          { label: 'Science', value: 'science' },
          { label: 'Art', value: 'art' }
        ]}
      />
      <div className='mt-3'>
        {data.map(item => <div key={item.id}>{item.label}</div>)}
      </div>
    </div>
  )
}

The useEffect dependency array [category] tells React to re-execute the fetching logic whenever the category state is updated by the CSelect component. This ensures the UI stays in sync with the user’s selection without manual event listener management. You can also apply client-side transformations to the fetched data, such as filtering an array in JavaScript.

Async/Await in Event Handlers

While useEffect is for side effects on render, you often need async/await in event handlers like form submissions or button clicks.

import React, { useState } from 'react'
import { CButton, CFormInput } from '@coreui/react'
// Link to documentation: https://coreui.io/react/docs/components/button/

const SubscriptionForm = () => {
  const [email, setEmail] = useState('')
  const [status, setStatus] = useState('idle')

  const handleSubmit = async (event) => {
    event.preventDefault()
    
    // Validate email is not empty
    if (!email) return

    setStatus('submitting')

    try {
      const response = await fetch('https://api.example.com/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email })
      })

      if (response.ok) {
        setStatus('success')
        setEmail('')
      } else {
        setStatus('error')
      }
    } catch (err) {
      setStatus('error')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <CFormInput 
        type='email' 
        value={email} 
        onChange={(e) => setEmail(e.target.value)} 
        placeholder='Enter email'
      />
      <CButton 
        type='submit' 
        color='primary' 
        disabled={status === 'submitting'}
        className='mt-2'
      >
        {status === 'submitting' ? 'Submitting...' : 'Subscribe'}
      </CButton>
      {status === 'success' && <p className='text-success'>Thanks for subscribing!</p>}
    </form>
  )
}

In event handlers, you can mark the function as async directly. This makes it easy to handle sequential asynchronous steps, such as validating a user, sending data to an API, and then redirecting the user or showing a success message. Using the disabled prop on the CButton prevents multiple clicks while the request is pending.

Extracting Data Fetching to Custom Hooks

To keep components clean and reusable, it is best practice to extract async/await logic into custom hooks.

import { useState, useEffect } from 'react'

const useFetch = (url) => {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    let isMounted = true
    const controller = new AbortController()

    const fetchData = async () => {
      try {
        const response = await fetch(url, { signal: controller.signal })
        const result = await response.json()
        if (isMounted) {
          setData(result)
          setLoading(false)
        }
      } catch (err) {
        if (isMounted) setLoading(false)
      }
    }

    fetchData()

    return () => {
      isMounted = false
      controller.abort()
    }
  }, [url])

  return { data, loading }
}

// Usage in a component:
// const { data, loading } = useFetch('https://api.example.com/profile')

By wrapping the async logic in a custom hook, you separate the “how” (fetching) from the “what” (rendering). The isMounted flag is an additional safety measure to ensure we don’t update state if the component has already unmounted, which is a common pattern in high-performance CoreUI applications. This clean separation of concerns makes your code much easier to test and maintain across large projects.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author