React  

How to Handle API Calls and Loading States in React

Introduction

Working with APIs is a core part of building real-world React applications. Whether you are fetching user data, product lists, or dashboard stats, it's important to handle API calls correctly. Poorly managed API logic can cause bugs like repeated API calls, missing loading spinners, or inconsistent UI. In this article, you will learn how to make API calls in React using clean, simple code — and how to manage loading, success, and error states using best practices.

Why Handling API Calls Correctly Matters

Incorrect API handling can lead to:

  • UI flickering or blank screens

  • Multiple API calls due to re-renders

  • Errors not showing to the user

  • Loading states not updating correctly

  • Memory leaks when using async functions inside useEffect

With proper structure, you can keep your React UI smooth, predictable, and error-free.

Basic API Call in React Using useEffect

The most common way to fetch data in React is using useEffect and fetch or axios.

Example

import { useEffect, useState } from "react";

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

  useEffect(() => {
    fetch("https://api.example.com/products")
      .then(response => response.json())
      .then(result => {
        setData(result);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  return <div>...</div>;
}

What This Code Does

  • loading begins as true

  • API request runs once (because dependency array is empty)

  • If successful → stores data and hides loading

  • If error → shows error message and hides loading

This structure ensures a clean and predictable UI.

Using Async/Await for Cleaner API Code

Using .then() works fine but async/await is easier to read.

Example

useEffect(() => {
  const fetchData = async () => {
    try {
      setLoading(true);
      const response = await fetch("https://api.example.com/users");
      const json = await response.json();
      setData(json);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  fetchData();
}, []);

Why This Is Better

  • Code looks cleaner

  • try/catch/finally makes error handling clear

  • All states are handled in one place

Showing a Proper Loading Spinner

Users should not see a blank page while data loads.

Example UI

if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;

Why This Helps

  • Prevents confusion

  • Improves user experience

  • Ensures UI displays only when data is ready

You can replace <p>Loading...</p> with a real spinner component.

Handling Errors Correctly

API errors are common — wrong URL, network failure, server down.

Display Error to User

if (error) return <div>Something went wrong: {error}</div>;

Example of UI After Error

  • "Failed to fetch"

  • "Server error"

  • "Network connection lost"

Clear error messages guide users, instead of showing a broken screen.

Preventing Multiple API Calls (Important)

React may re-render components often. If your API call is inside useEffect without proper dependency control, it may fire multiple times.

Common Mistake

useEffect(() => {
  fetchData();
});

This runs on every render.

Correct Approach

useEffect(() => {
  fetchData();
}, []);

Now it runs once, on initial render.

Canceling API Calls to Avoid Memory Leaks

When leaving a page during an API call, React may show warnings like:

Can't perform a React state update on an unmounted component

To avoid this, use an abort controller.

Example

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

  const loadUsers = async () => {
    try {
      const res = await fetch("https://api.example.com/users", {
        signal: controller.signal,
      });

      const json = await res.json();
      setData(json);
    } catch (err) {
      if (err.name !== "AbortError") {
        setError(err.message);
      }
    }
  };

  loadUsers();

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

Why This Matters

  • Prevents memory leaks

  • Avoids state updates after component unmounts

  • Makes code production-ready

Using Custom Hooks for Clean and Reusable API Logic

To avoid repeating loading, error, and fetching logic, create a custom hook.

Custom Hook Example: useFetch

import { useEffect, useState } from "react";

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

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        const json = await response.json();
        setData(json);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading, error };
}

Using the Hook

const { data, loading, error } = useFetch("https://api.example.com/posts");

This keeps your components clean and readable.

Best Practices for API Handling in React

  • Always show loading and error states

  • Use async/await for readable code

  • Prevent multiple API calls using dependency arrays

  • Clean up API calls using AbortController

  • Extract logic into custom hooks for reusability

  • Avoid updating UI before data is ready

  • Validate API responses before using them in UI

Conclusion

Handling API calls the right way is crucial for building professional and stable React applications. By managing loading and error states, preventing unnecessary API calls, cleaning up requests, and using reusable hooks, you can create a smooth user experience and avoid common bugs. With these techniques, your React apps will load data correctly, show helpful feedback, and perform better overall.