Paramclasses Academy - Blog

Tutorials Details

←
Post 15 of 29
→

What is Handling Loading States, Errors, and Empty Data in React Applications ?

What is Handling Loading States, Errors, and Empty Data in React Applications ?

October 29, 2026

Introduction

Modern React applications frequently communicate with APIs to retrieve data from external servers. However, API requests do not always complete immediately or succeed every time.

For example:

  • A user opens a product page while product information is loading.

  • A server returns an error while the application requests data.

  • A search returns no matching results.

  • An internet connection fails during an API request.

  • A user wants to retry a failed request.

A professional application should handle these situations clearly instead of displaying a blank screen or confusing information.

In this tutorial, we will learn how to build reliable user interfaces using loading states, error states, empty states, and retry functionality.

2. Learning Objectives

After completing this tutorial, you will be able to:

  1. Understand loading, success, error, and empty states.

  2. Manage asynchronous UI state using React's useState Hook.

  3. Display loading indicators while API requests are running.

  4. Show useful error messages when requests fail.

  5. Display empty states when no data is available.

  6. Implement a retry button.

  7. Avoid race conditions and handle request cancellation.

  8. Build a reusable API-driven component.

  9. Follow best practices for robust React applications.

3. Core Concepts and Explanation

3.1 What Is a Loading State?

A loading state tells the user that the application is currently waiting for data.

For example:

if (loading) {
  return <p>Loading products...</p>;
}

Without a loading indicator, users may think the page is broken or that their action did not work.

Loading UI can include:

  • A text message.

  • A spinner.

  • A skeleton placeholder.

  • A progress indicator.

The appropriate indicator depends on the application and the expected loading duration.

3.2 What Is an Error State?

An error state appears when an operation cannot complete successfully.

Possible causes include:

  • Network connection failures.

  • HTTP errors such as 404 or 500.

  • Authentication or authorization failures.

  • Invalid response data.

  • Server-side problems.

Example:

if (error) {
  return <p role="alert">Error: {error}</p>;
}

A useful error message should tell users what happened and, when appropriate, how they can recover.

Avoid exposing sensitive technical details, server secrets, or internal stack traces to users.

3.3 What Is an Empty State?

An empty state appears when the operation succeeds but there is no data to display.

For example, a search for a product may return an empty array.

if (products.length === 0) {
  return <p>No products found.</p>;
}

An empty state is not necessarily an error. It may simply mean that the user has not added any items or that a search has no matches.

3.4 What Is a Success State?

A success state appears when data has been retrieved successfully and is available for display.

Example:

return (
  <ul>
    {products.map(product => (
      <li key={product.id}>{product.title}</li>
    ))}
  </ul>
);

3.5 Understanding the Four Common UI States

State Meaning Example UI
Loading Request is in progress Loading spinner
Success Data is available Product list
Error Request failed Error message and retry button
Empty Request succeeded but returned no items No products found

These states should be designed deliberately so users always understand what the application is doing.

3.6 Managing State in React

A simple implementation may use three state variables:

const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");

Here:

  • data stores the retrieved information.

  • loading indicates whether a request is in progress.

  • error stores an error message when a request fails.

For more complex interfaces, you can use a single status variable or a dedicated data-fetching library. The best approach depends on the number of states and how they interact.

3.7 Why Check response.ok?

JavaScript's fetch() does not automatically reject its Promise for HTTP responses such as 404 Not Found or 500 Internal Server Error.

Therefore, check the response:

const response = await fetch(url);

if (!response.ok) {
  throw new Error(`Request failed: ${response.status}`);
}

const data = await response.json();

This ensures that unsuccessful HTTP responses enter the error-handling path.

4. Practical Code Examples

Example 1: Basic Loading State

Create or update src/App.jsx:

import { useEffect, useState } from "react";

function App() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function loadProducts() {
      try {
        const response = await fetch(
          "https://fakestoreapi.com/products"
        );

        if (!response.ok) {
          throw new Error("Failed to load products");
        }

        const data = await response.json();
        setProducts(data);
      } catch (error) {
        console.error(error);
      } finally {
        setLoading(false);
      }
    }

    loadProducts();
  }, []);

  if (loading) {
    return <h2>Loading products...</h2>;
  }

  return (
    <div>
      <h1>Product Catalog</h1>

      {products.map(product => (
        <article key={product.id}>
          <h2>{product.title}</h2>
          <p>${product.price}</p>
        </article>
      ))}
    </div>
  );
}

export default App;

Explanation:

  1. Initially, loading is true.

  2. The API request starts inside useEffect.

  3. If the request succeeds, product data is stored in state.

  4. The finally block sets loading to false, whether the request succeeds or fails.

  5. React renders the product list after loading finishes.

This is a basic example. It logs failures but does not yet display a user-facing error message.

Example 2: Add Error Handling

Now improve the component by displaying an error message.

import { useEffect, useState } from "react";

function App() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");

  useEffect(() => {
    async function loadProducts() {
      try {
        const response = await fetch(
          "https://fakestoreapi.com/products"
        );

        if (!response.ok) {
          throw new Error(
            `Unable to load products (${response.status})`
          );
        }

        const data = await response.json();
        setProducts(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    loadProducts();
  }, []);

  if (loading) {
    return <p>Loading products...</p>;
  }

  if (error) {
    return <p role="alert">Error: {error}</p>;
  }

  return (
    <div>
      <h1>Products</h1>

      {products.map(product => (
        <article key={product.id}>
          <h2>{product.title}</h2>
          <p>${product.price}</p>
        </article>
      ))}
    </div>
  );
}

export default App;

Explanation:

  • try contains the request and response processing.

  • catch stores the error message.

  • finally ends the loading state.

  • The conditional returns display loading and error UI before the product list.

The role="alert" attribute helps assistive technologies identify important error messages.

Example 3: Handle Empty Data

An API may successfully return an empty array. This should not be treated as a request failure.

import { useEffect, useState } from "react";

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");

  useEffect(() => {
    async function loadProducts() {
      try {
        const response = await fetch(
          "https://fakestoreapi.com/products"
        );

        if (!response.ok) {
          throw new Error("Failed to load products");
        }

        const data = await response.json();
        setProducts(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    loadProducts();
  }, []);

  if (loading) {
    return <p>Loading products...</p>;
  }

  if (error) {
    return <p role="alert">Error: {error}</p>;
  }

  if (products.length === 0) {
    return <p>No products available right now.</p>;
  }

  return (
    <div>
      <h1>Product Catalog</h1>

      {products.map(product => (
        <article key={product.id}>
          <h2>{product.title}</h2>
          <p>${product.price}</p>
        </article>
      ))}
    </div>
  );
}

export default ProductList;

Important distinction: The empty-state check must happen after loading and error checks. Otherwise, the component may show “No products available” before the API request finishes.

Example 4: Implement a Retry Button

A retry button lets users attempt the request again after a failure.

import { useState } from "react";

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  async function loadProducts() {
    setLoading(true);
    setError("");

    try {
      const response = await fetch(
        "https://fakestoreapi.com/products"
      );

      if (!response.ok) {
        throw new Error("Could not load products");
      }

      const data = await response.json();
      setProducts(data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      <h1>Product Catalog</h1>

      <button onClick={loadProducts} disabled={loading}>
        {loading ? "Loading..." : "Load Products"}
      </button>

      {loading && <p>Fetching products...</p>}

      {error && (
        <div>
          <p role="alert">Error: {error}</p>

          <button onClick={loadProducts} disabled={loading}>
            Retry
          </button>
        </div>
      )}

      {!loading && !error && products.length === 0 && (
        <p>No products loaded yet.</p>
      )}

      {!loading && !error && products.length > 0 && (
        <div>
          {products.map(product => (
            <article key={product.id}>
              <h2>{product.title}</h2>
              <p>${product.price}</p>
            </article>
          ))}
        </div>
      )}
    </div>
  );
}

export default ProductList;

Explanation:

  • The request begins when the user clicks “Load Products.”

  • The loading state disables the buttons while the request is in progress.

  • If the request fails, an error and Retry button appear.

  • Clicking Retry calls the same request function again.

  • If the request succeeds, the product list appears.

This example uses a user-triggered request, so the event handler is responsible for starting the operation. An effect is not required just to respond to the button click.

Example 5: Build a Reusable API State Component

In a larger application, you may want consistent loading, error, and empty-state UI across several pages.

function ApiState({
  loading,
  error,
  isEmpty,
  onRetry,
  children
}) {
  if (loading) {
    return <p role="status">Loading data...</p>;
  }

  if (error) {
    return (
      <div>
        <p role="alert">{error}</p>
        <button onClick={onRetry}>Try Again</button>
      </div>
    );
  }

  if (isEmpty) {
    return <p>No data available.</p>;
  }

  return children;
}

export default ApiState;

Example usage:

<ApiState
  loading={loading}
  error={error}
  isEmpty={products.length === 0}
  onRetry={loadProducts}
>
  <ProductGrid products={products} />
</ApiState>

In this example, ProductGrid represents a product-display component that you would create separately.

Reusable components help maintain consistent messaging and reduce repeated UI code. Keep in mind that the parent component must still implement the request logic and pass the correct state values.

5. Real-World Applications

E-commerce Website

  • Show skeleton cards while products load.

  • Display a retry button if the product API fails.

  • Show “No products found” when a category contains no items.

Weather Application

  • Show a loading spinner while fetching weather data.

  • Display a helpful message if the city cannot be found.

  • Allow users to retry after a network failure.

Admin Dashboard

  • Display loading placeholders for reports.

  • Show access-denied or server-error messages where appropriate.

  • Distinguish between a successful response containing no records and a failed request.

Search Application

  • Show loading feedback while searching.

  • Display a “No matching results” message for an empty search response.

  • Prevent duplicate submissions while a request is running.

6. Common Mistakes and Best Practices

Mistake 1: Showing an Empty State During Loading

Incorrect:

if (products.length === 0) {
  return <p>No products found.</p>;
}

If the request is still running, this may display a misleading message.

Best practice: Check loading before checking whether the data array is empty.

Mistake 2: Forgetting to Reset the Error

When retrying a failed request, clear the previous error before starting again:

setError("");
setLoading(true);

Otherwise, the previous error may remain visible after a new request begins.

Mistake 3: Forgetting to Check HTTP Status

fetch() can resolve with a 404 or 500 response. Always check response.ok when the application expects a successful HTTP response.

Mistake 4: Allowing Duplicate Requests

Repeated clicks may trigger overlapping requests.

Best practice: Disable the request button while loading. For complex applications, consider request deduplication or a data-fetching library.

Mistake 5: Ignoring Outdated Requests

If the request inputs change, an older request may finish after a newer one and overwrite its results.

Best practice: Use AbortController or a stale-result guard where appropriate.

Mistake 6: Displaying Raw Technical Errors

Avoid displaying internal stack traces or sensitive backend information directly to users.

Best practice: Show a concise, user-friendly message and keep detailed diagnostic information in appropriate logs.

Best-Practice Checklist

  • Handle loading, success, error, and empty states separately.

  • Check HTTP status codes.

  • Reset errors when starting a new request.

  • Disable duplicate submissions when necessary.

  • Cancel or ignore outdated requests.

  • Use accessible status and alert messaging.

  • Keep request logic separate from reusable presentation components.

  • Do not expose private API keys or internal server details.

7. Practice Exercises

Exercise 1: Loading Indicator

Create a component that fetches posts and displays “Loading posts...” while the request is in progress.

Exercise 2: Error Handling

Display an error message when the request fails. Make sure HTTP errors are detected using response.ok.

Exercise 3: Empty State

Create a product list that displays “No products found” when the API returns an empty array.

Exercise 4: Retry Functionality

Add a Retry button that starts the request again after a failure. Disable the button while loading.

Exercise 5: Reusable API State

Create a reusable ApiState component that handles loading, error, and empty states, and renders its children when data is available.

Exercise 6: Improve Accessibility

Use appropriate role="status" and role="alert" attributes for loading and error messages.

8. Key Takeaways and Conclusion

In this tutorial, you learned:

  • Loading states communicate ongoing operations.

  • Error states help users understand failed requests.

  • Empty states distinguish successful requests with no results from actual failures.

  • Success states display the retrieved data.

  • Retry buttons make recovery easier.

  • HTTP status codes must be checked explicitly.

  • Reusable components help maintain consistent UI behavior.

  • Request cancellation and duplicate-request prevention improve reliability.

Final takeaway: A professional React application should clearly communicate every stage of an asynchronous operation instead of leaving users with a blank screen or misleading information.

←
Post 15 of 29
→