React  

Best Practices for Handling API Errors and Loading States in React

When you build applications in React, most of your components rely on data fetched from APIs. These APIs can fail, take time to respond, or even return unexpected results. How you handle loading and error states directly impacts your app’s reliability and user experience.

If users see blank screens, never-ending spinners, or confusing error messages, they will lose trust quickly. Managing these states well is not just about avoiding bugs; it is about delivering a smooth and predictable experience.

In this article, we will cover the best practices for managing API errors and loading states in React applications. You will learn practical approaches, patterns, and real-world examples that help create stable and polished interfaces.

Why Error and Loading Handling Matter

When fetching data in React, there are three possible states to manage:

  1. Loading state: The app is waiting for the API response.

  2. Success state: Data has arrived successfully.

  3. Error state: Something went wrong while fetching or processing data.

If any of these are not handled properly, the user experience suffers. Imagine a dashboard that stays blank while data loads, or a page that crashes when the API fails. Proper state handling ensures your UI communicates clearly with users at every step.

Good error and loading management also improves developer experience. It prevents redundant code, reduces debugging time, and encourages consistent patterns across components.

Start with a Clean Data Fetching Pattern

The most common way to fetch data in React is by using the useEffect and useState hooks. A simple structure looks like this:

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

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

  useEffect(() => {
    fetch('/api/users')
      .then(response => {
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        return response.json();
      })
      .then(data => {
        setUsers(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

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

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

export default UserList;

This example covers all three states. Although it works, it can become repetitive if you use this pattern across many components. The goal is to extract and reuse these patterns in smarter ways.

1. Centralize Data Fetching Logic

Instead of repeating fetch logic in every component, move it to a reusable function or custom hook. Custom hooks help separate concerns and make your code easier to maintain.

Here’s how you can create a useFetch hook:

import { useEffect, useState } from 'react';

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

  useEffect(() => {
    let isMounted = true;

    fetch(url)
      .then(response => {
        if (!response.ok) {
          throw new Error('Failed to fetch');
        }
        return response.json();
      })
      .then(json => {
        if (isMounted) {
          setData(json);
          setLoading(false);
        }
      })
      .catch(err => {
        if (isMounted) {
          setError(err.message);
          setLoading(false);
        }
      });

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

  return { data, loading, error };
}

export default useFetch;

You can now use it anywhere:

function Posts() {
  const { data: posts, loading, error } = useFetch('/api/posts');

  if (loading) return <p>Loading posts...</p>;
  if (error) return <p>Could not load posts: {error}</p>;

  return (
    <div>
      {posts.map(p => (
        <h3 key={p.id}>{p.title}</h3>
      ))}
    </div>
  );
}

This pattern keeps your components focused on rendering while your hook handles the network logic.

2. Always Provide Meaningful Feedback

When users perform actions like loading data, submitting forms, or refreshing content, they should see clear feedback. Avoid vague text like “Something went wrong.” Instead, use messages that explain what happened and how to fix it if possible.

Examples of clear feedback:

  • “Unable to connect to the server. Please check your internet connection.”

  • “No posts found. Try creating your first one.”

  • “Data failed to load. Tap to retry.”

You can even provide retry buttons for recoverable errors:

function ErrorMessage({ message, onRetry }) {
  return (
    <div>
      <p>{message}</p>
      <button onClick={onRetry}>Retry</button>
    </div>
  );
}

When combined with your custom hook:

function Products() {
  const { data, loading, error } = useFetch('/api/products');

  if (loading) return <p>Loading products...</p>;
  if (error) return <ErrorMessage message={error} onRetry={() => window.location.reload()} />;

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

Meaningful messages and recovery options turn frustrating experiences into smoother interactions.

3. Use Suspense and Error Boundaries for Declarative Loading

React Suspense allows you to declaratively handle loading states without manual conditional rendering. Although Suspense is often associated with lazy loading components, React 18 also supports it for data fetching when integrated with libraries like React Query or Relay.

Example with React.lazy and Suspense

import React, { Suspense, lazy } from 'react';

const Profile = lazy(() => import('./Profile'));

function App() {
  return (
    <Suspense fallback={<p>Loading profile...</p>}>
      <Profile />
    </Suspense>
  );
}

You can pair Suspense with Error Boundaries to handle both loading and error states together.

Error Boundary Example

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    console.error('Error:', error, info);
  }

  render() {
    if (this.state.hasError) {
      return <p>Something went wrong while loading this section.</p>;
    }
    return this.props.children;
  }
}

Combining both

<ErrorBoundary>
  <Suspense fallback={<p>Loading content...</p>}>
    <MyComponent />
  </Suspense>
</ErrorBoundary>

This creates a clean structure for managing both types of asynchronous states.

4. Use a Data Fetching Library

While custom hooks are great for small projects, larger apps benefit from a dedicated data-fetching library. Libraries like React Query (TanStack Query), SWR, or RTK Query simplify fetching, caching, error handling, and retry logic.

Here’s an example using React Query:

import { useQuery } from '@tanstack/react-query';

function TodoList() {
  const { data, isLoading, isError, error, refetch } = useQuery({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then(res => res.json())
  });

  if (isLoading) return <p>Loading todos...</p>;
  if (isError) return <ErrorMessage message={error.message} onRetry={refetch} />;

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

React Query automatically retries failed requests, caches results, and keeps data fresh. You no longer need to manually handle all these details.

5. Keep Your Loading Indicators Consistent

Users get frustrated when one page shows a spinner, another shows a skeleton loader, and another uses plain text. Decide on a consistent loading pattern across your application.

Popular patterns include:

  • Spinners: Simple indicators for short waits.

  • Skeleton Loaders: Shimmer effects or placeholders for longer fetch times.

  • Progress Bars: Useful when you know the approximate progress.

For instance, a skeleton loader can improve the perceived performance:

function SkeletonCard() {
  return (
    <div className="skeleton-card">
      <div className="skeleton-avatar" />
      <div className="skeleton-text" />
    </div>
  );
}

function UserListSkeleton() {
  return (
    <div>
      {Array.from({ length: 5 }).map((_, i) => (
        <SkeletonCard key={i} />
      ))}
    </div>
  );
}

By reusing these loaders across pages, your UI feels more unified and professional.

6. Handle Global Errors Gracefully

Not every error can be caught locally. Sometimes APIs fail globally, authentication tokens expire, or network connectivity drops.

To handle such scenarios, use global error boundaries or a context-based error handler. For instance, you can wrap your entire app in a context that listens for global errors and displays a fallback UI.

import { createContext, useContext, useState } from 'react';

const ErrorContext = createContext();

export function ErrorProvider({ children }) {
  const [globalError, setGlobalError] = useState(null);

  return (
    <ErrorContext.Provider value={{ globalError, setGlobalError }}>
      {globalError ? <p>{globalError}</p> : children}
    </ErrorContext.Provider>
  );
}

export function useGlobalError() {
  return useContext(ErrorContext);
}

This helps centralize major failures, such as token expiration or API downtime.

7. Avoid Infinite Spinners

A common mistake is forgetting to handle cases where a request never completes. Always include a timeout or fallback mechanism.

Example

useEffect(() => {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10000); // 10 seconds

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => setError(err.message))
    .finally(() => clearTimeout(timeout));

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

This ensures the user is not left waiting forever when something goes wrong.

8. Test Your Loading and Error Scenarios

Many developers test only the success state. That’s a mistake. Always simulate slow networks and failed responses using browser dev tools or mock APIs.

Check how your app behaves:

  • When the network is offline.

  • When the API returns a 500 error.

  • When the data format is incorrect.

  • When a request times out.

By testing these situations early, you prevent unpleasant surprises in production.

Conclusion

Handling API errors and loading states correctly is one of the most important aspects of building a reliable React application. It directly shapes how users perceive performance, stability, and quality.

To summarize the best practices:

  1. Keep your fetch logic clean using custom hooks or data libraries.

  2. Always provide clear and meaningful feedback to users.

  3. Use Suspense and Error Boundaries for declarative handling.

  4. Ensure consistent loaders across your app.

  5. Implement retry and timeout mechanisms.

  6. Test every possible error scenario.

When you combine these practices, you create React applications that not only look polished but also feel trustworthy and responsive.

A good React app does not just work when everything goes right. It handles slow, failed, or partial responses gracefully and keeps users informed at every step.