Site icon Full-Stack

React API Fetching

Developer fetching data from an API in a React application on a laptop screen

A visual guide to fetching data from APIs in React using hooks and modern libraries

If you have spent any time building React applications, you already know that pulling data from an API is one of those tasks that looks simple on paper but hides a surprising number of decisions underneath. Do you use fetch or axios? Where does the request live inside your component? What happens when the request fails or takes too long? How do you avoid showing stale data to your users? These are not edge cases; they are the everyday reality of working with real APIs, and getting them right is what separates a shaky prototype from an app people can actually trust.

In this guide we are going to walk through the practical side of fetching data in React, starting with the basics and moving toward patterns that scale well as your app grows. Whether you are building a small dashboard or a full production app, the ideas here will help you write cleaner, more reliable data fetching code.

Why Data Fetching in React Needs Special Care

React components render and re render constantly, sometimes for reasons that have nothing to do with your data request. If you are not careful, this can lead to duplicate network calls, memory leaks, or components trying to update state after they have already been removed from the screen. On top of that, network requests are inherently unpredictable. They can be slow, they can fail, and they can return unexpected shapes of data. A good data fetching strategy accounts for all of this instead of assuming the happy path will always happen.

Using the useEffect Hook for Basic Fetching

The most common starting point for fetching data in a function component is the useEffect hook combined with useState. Here is a simple example of fetching a list of users from a public API.

function UserList() { const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null)

useEffect(() => { let isMounted = true

fetch(‘https://jsonplaceholder.typicode.com/users‘) .then(response => { if (!response.ok) { throw new Error(‘Something went wrong while fetching users’) } return response.json() }) .then(data => { if (isMounted) { setUsers(data) setLoading(false) } }) .catch(err => { if (isMounted) { setError(err.message) setLoading(false) } })

return () => { isMounted = false } }, [])

if (loading) return <p>Loading users…</p> if (error) return <p>Error: {error}</p>

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

Notice the isMounted flag inside the effect. This small addition prevents React from trying to update state on a component that has already unmounted, which is a common source of console warnings and subtle bugs in larger apps.

Handling Loading and Error States Properly

New developers often fetch data and forget to plan for the in between moments. Users do not experience your API instantly, they experience the seconds before the data arrives, and what they see during that time matters a lot for perceived performance. At minimum your components should account for three states, loading, success, and error. Skipping the error state is especially risky because APIs fail more often than people expect, whether due to rate limits, expired tokens, server downtime, or simple network hiccups on the user’s side.

A practical tip here is to design your error messages around what the user can actually do next. Instead of showing a generic message, try something like “We could not load your data, please check your connection and try again.” This small change in wording makes your app feel more thoughtful and less broken.

Using Async and Await for Cleaner Code

Promise chains work fine, but many developers prefer the readability of async and await syntax, especially when a component needs to fetch data from more than one endpoint. Here is the same user fetching example rewritten with async and await.

useEffect(() => { const controller = new AbortController()

async function loadUsers() { try { const response = await fetch(‘https://jsonplaceholder.typicode.com/users‘, { signal: controller.signal }) if (!response.ok) { throw new Error(‘Request failed with status ‘ + response.status) } const data = await response.json() setUsers(data) } catch (err) { if (err.name !== ‘AbortError’) { setError(err.message) } } finally { setLoading(false) } }

loadUsers()

return () => controller.abort() }, [])

Using AbortController here is a small but meaningful upgrade. If the user navigates away from the page before the request finishes, the fetch is cancelled instead of continuing in the background, which saves bandwidth and avoids unnecessary state updates.

Choosing Between Fetch and Axios

The built in fetch API is available in every modern browser and requires no extra dependency, which makes it a solid default choice for smaller projects. Axios, on the other hand, has been a long time favorite in the React community because it automatically parses JSON, has cleaner syntax for setting headers and timeouts, and provides more consistent error handling across different browsers and environments.

If your project already deals with complex API interactions, file uploads, or you need request and response interceptors for things like attaching auth tokens automatically, axios can save you a good amount of boilerplate. For simpler apps or when you want to keep your dependency list light, fetch is usually enough.

Why You Should Consider a Data Fetching Library

As your app grows, manually managing loading states, caching, retries, and re fetching on every component starts to become repetitive and error prone. This is where libraries like React Query, now known as TanStack Query, and SWR come in. Both of these libraries were built specifically to solve the pain points of server state management in React.

Here is what the earlier user fetching example looks like using TanStack Query.

function UserList() { const { data, isLoading, error } = useQuery({ queryKey: [‘users’], queryFn: async () => { const response = await fetch(‘https://jsonplaceholder.typicode.com/users‘) if (!response.ok) { throw new Error(‘Failed to fetch users’) } return response.json() } })

if (isLoading) return <p>Loading users…</p> if (error) return <p>Error: {error.message}</p>

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

Notice how much of the manual state management disappears. TanStack Query handles caching, background refetching, retry logic on failure, and even deduplicates identical requests fired from different components at the same time. For any app that fetches data on more than a handful of screens, adopting a library like this early on will save you a significant amount of debugging time later.

Building a Reusable Custom Hook

If you are not ready to bring in a full library, a good middle ground is writing your own reusable custom hook for fetching data. This keeps your components clean and puts all the fetching logic in one place that you can test and reuse.

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

useEffect(() => { const controller = new AbortController() setLoading(true)

fetch(url, { signal: controller.signal }) .then(res => { if (!res.ok) throw new Error(‘Request failed’) return res.json() }) .then(data => { setData(data) setError(null) }) .catch(err => { if (err.name !== ‘AbortError’) setError(err.message) }) .finally(() => setLoading(false))

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

return { data, loading, error } }

Now any component can simply call const { data, loading, error } = useFetch(‘/api/products’) and get consistent behavior across your entire app.

Common Mistakes to Avoid

One mistake developers make repeatedly is fetching data directly inside the component body instead of inside useEffect, which causes an infinite loop of requests because every render triggers a new fetch. Another common issue is forgetting to include dependencies in the useEffect dependency array, which either causes stale closures or unnecessary re fetching. It is also easy to forget to handle the case where the API returns an empty array or null instead of the expected object shape, which can crash your component if you try to map over it without checking first.

A less obvious but important mistake is not debouncing search input when fetching data based on user typing. Firing a network request on every keystroke can overwhelm your API and create a laggy experience. Adding a simple debounce of around 300 to 500 milliseconds before triggering the fetch makes search features feel far smoother.

Tips for Production Ready Data Fetching

Always set reasonable timeouts on your requests so a hanging connection does not leave your UI stuck in a loading state forever. Cache responses when the data does not change often, since repeated calls to the same endpoint waste both time and server resources. Centralize your API base URL and headers in one configuration file rather than repeating them across components, which makes switching environments between development and production much easier. Finally, log errors somewhere you can actually see them, whether that is a simple console log during development or a proper monitoring tool like Sentry in production, because silent failures are much harder to fix than loud ones.

Wrapping Up

Fetching data from APIs in React does not have to be complicated, but it does require intention. Start with the fundamentals using useEffect and proper state management, move to async and await for readability, and consider a dedicated library like TanStack Query once your app’s data needs grow beyond a few simple requests. Along the way, remember to handle loading and error states with the same care you give to the success state, since that is often what users actually experience while waiting for your app to respond. With these patterns in place, your React app will feel faster, more reliable, and far more resilient to the messy realities of working with real world APIs.

Please enable JavaScript in your browser to complete this form.
Please enable JavaScript in your browser to complete this form.
Name

Frequently Asked Questions

What is the best way to fetch data from an API in a React application?

The best way to fetch data from an API in a React application is by using the Fetch API or a library like Axios. These methods allow you to send HTTP requests to the API and handle the responses in your React components. This approach provides a straightforward and efficient way to interact with APIs in React.

How do I handle API request errors in my React application?

You can handle API request errors in your React application by using try-catch blocks and checking the response status codes. This allows you to catch and handle any errors that occur during the API request, providing a better user experience. Additionally, you can use error boundaries to catch and display error messages to the user.

Should I use the Fetch API or a library like Axios to fetch data from APIs in React?

The choice between the Fetch API and a library like Axios depends on your specific needs and preferences. Axios provides a more convenient and intuitive API, while the Fetch API provides a more lightweight and native solution. Both approaches can be effective, and the best choice will depend on the requirements of your application.

How do I optimize the performance of API requests in my React application?

You can optimize the performance of API requests in your React application by using techniques like caching, pagination, and debouncing. These techniques help reduce the number of API requests, minimize the amount of data transferred, and improve the overall responsiveness of your application. By implementing these optimizations, you can significantly improve the performance and user experience of your application.

Where should I put API request logic in my React application?

The API request logic should be placed in a separate utility file or a custom hook, rather than directly in your React components. This approach helps keep your components clean and focused on rendering the UI, while also making it easier to reuse and test the API request logic. By separating the API request logic, you can make your code more modular and maintainable.

Exit mobile version