DEV Community

Cover image for Handling API Errors & Loading States in React (Clean UX Approach)

Handling API Errors & Loading States in React (Clean UX Approach)

A user interface is like a joke. If you have to explain it, it is not that good. - Martin LeBlanc

In any real-world React application, you will spend far more time handling what goes wrong than celebrating what goes right. Network failures, slow responses, timeouts, and server errors are not edge cases - they are everyday realities. How you communicate these states to your users defines the quality of your application's user experience.

Key Takeaway

  • Always model three states - loading, error, and success - for every API call, no exceptions.
  • Provide meaningful, context-specific error messages - never expose raw error objects to users.
  • Use skeleton screens instead of spinners where possible to reduce perceived load time.
  • Implement retry logic with exponential backoff for transient network failures.
  • Centralize error handling with custom hooks to avoid repeating logic across components.
  • Distinguish between network errors, HTTP errors, and validation errors - each requires different UX.
  • Always cancel in-flight requests on component unmount to prevent memory leaks and ghost state updates

Table of Contents

  1. Introduction
  2. Understanding the Three API States
  3. Handling Loading States - Spinners, Skeletons & Beyond
  4. Handling API Errors - Graceful Degradation
  5. Building a Custom useFetch Hook
  6. Advanced Techniques
  7. Stats & Interesting Facts
  8. FAQ
  9. Conclusion

1. Introduction

Modern React applications are almost always data-driven, relying on REST APIs, GraphQL endpoints, or third-party services to power their interfaces. Whether you are building a dashboard that fetches analytics data, a shopping app that loads product listings, or a social platform that streams user posts, every API interaction introduces three inevitable possibilities: the data is loading, the data arrived successfully, or something went wrong.
The difference between a great app and a frustrating one often comes down to how gracefully these states are handled. Users who encounter a blank screen during loading, a cryptic "undefined is not an object" message when an error occurs, or an interface that silently fails will lose trust in your product instantly.
This article walks you through a complete, production-ready strategy for handling API errors and loading states in React - covering everything from basic useState patterns and skeleton screens to custom hooks, Axios interceptors, and React Query integration.

2. Understanding the Three API States

Before writing a single line of code, it is essential to model your data fetching around three distinct states. Every API call in your application should reflect exactly one of these states at any given moment:

2.1 The Loading State

The loading state exists from the moment you initiate a request until a response (successful or otherwise) is received. Failing to model this state means users see stale data, empty screens, or worse - an interface that appears broken. A loading state should always trigger visible feedback.

2.2 The Success State

When the API returns the expected data with a successful status code (typically 2xx), the UI transitions to the success state. This is where you render the actual content. Even here, you must consider edge cases like empty arrays, null values, or partially missing fields.

2.3 The Error State

Any response outside the 2xx range, a network timeout, or a connection failure triggers the error state. This is the most neglected of the three, yet it is critical. Users need to know what failed and, where possible, what they can do about it.
A foundational pattern using React's useState hook illustrates this model clearly:

import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
 const [user, setUser] = useState(null);
 const [loading, setLoading] = useState(true);
 const [error, setError] = useState(null);
 useEffect(() => {
   const controller = new AbortController();
   async function fetchUser() {
     try {
       setLoading(true);
       setError(null);
       const res = await fetch(`/api/users/${userId}`, {
         signal: controller.signal
       });
       if (!res.ok) {
         throw new Error(`Server error: ${res.status} ${res.statusText}`);
       }
       const data = await res.json();
       setUser(data);
     } catch (err) {
       if (err.name !== 'AbortError') {
         setError(err.message);
       }
     } finally {
       setLoading(false);
     }
   }
   fetchUser();
   return () => controller.abort(); // cleanup on unmount
 }, [userId]);

 if (loading) return <LoadingSkeleton />;
 if (error) return <ErrorMessage message={error} />;
 return <ProfileCard user={user} />;
}
Enter fullscreen mode Exit fullscreen mode

Design is not just what it looks like and feels like. Design is how it works. - Steve Jobs

3. Handling Loading States - Spinners, Skeletons & Beyond

Not all loading indicators are equal. The choice of loading UI dramatically affects how users perceive your application's speed. Research in UX psychology consistently shows that users tolerate waiting much better when they receive structured, meaningful feedback.

3.1 Spinners - When to Use Them

Spinners (circular progress indicators) are appropriate for short, unpredictable waits - typically actions like form submissions, delete confirmations, or quick one-off requests. They signal activity without implying structure about what is loading. Use them sparingly; overuse of spinners across an interface feels chaotic.

function Spinner() {
 return (
   <div className="spinner-wrapper" role="status" aria-label="Loading">
     <div className="spinner" />
     <span className="sr-only">Loading...</span>
   </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

3.2 Skeleton Screens - The Gold Standard

Skeleton screens render placeholder shapes that mimic the final layout of your content before data arrives. They dramatically reduce perceived loading time by orienting the user to the page structure immediately. Facebook, LinkedIn, YouTube, and Slack all rely heavily on skeleton screens for their primary content feeds.

function ProductCardSkeleton() {
 return (
   <div className="card skeleton">
     <div className="skeleton-image" />
     <div className="skeleton-line wide" />
     <div className="skeleton-line medium" />
     <div className="skeleton-line narrow" />
   </div>
 );
}

// CSS for pulsing animation
/*
.skeleton-line {
 height: 16px;
 background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
 background-size: 200% 100%;
 animation: shimmer 1.5s infinite;
 border-radius: 4px;
 margin-bottom: 10px;
}
@keyframes shimmer {
 0% { background-position: 200% 0; }
 100% { background-position: -200% 0; }
}
*/
Enter fullscreen mode Exit fullscreen mode

3.3 Progressive Loading

For data-heavy pages, consider progressive loading: render critical above-the-fold content first, then fetch secondary sections lazily. This approach, combined with React's Suspense boundaries, delivers a fast initial paint even on slower connections.

import { Suspense, lazy } from 'react';
const HeavyChartSection = lazy(() => import('./HeavyChartSection'));
function Dashboard() {
 return (
   <div>
     <HeroMetrics />  {/* Loads immediately */}
     <Suspense fallback={<ChartSkeleton />}>
       <HeavyChartSection />  {/* Loads lazily */}
     </Suspense>
   </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

4. Handling API Errors - Graceful Degradation

Error handling is where most React applications fall short. A robust error handling strategy distinguishes between different failure modes and responds to each appropriately.

4.1 Classifying Errors

Not all errors are the same. Build your error handling logic around these distinct categories:

  • Network Errors - The request never reached the server (offline, DNS failure, CORS). The user needs to check their connection.
  • HTTP 4xx Errors - Client-side errors (401 Unauthorized, 403 Forbidden, 404 Not Found, 422 Validation Failed). Each requires a specific response.
  • HTTP 5xx Errors - Server-side errors (500 Internal Server Error, 503 Service Unavailable). The user should be reassured it is not their fault.
  • Timeout Errors - The request took too long. Often appropriate to offer a retry.
  • Validation Errors - The server rejected the data (422). Field-level feedback is critical here.
function classifyError(err, statusCode) {
 if (!statusCode) return { type: 'network', message: 'No internet connection. Please check your network.' };
 if (statusCode === 401) return { type: 'auth', message: 'Your session has expired. Please log in again.' };
 if (statusCode === 403) return { type: 'forbidden', message: 'You do not have permission to view this resource.' };
 if (statusCode === 404) return { type: 'notfound', message: 'The requested resource could not be found.' };
 if (statusCode === 422) return { type: 'validation', message: 'Please check your input and try again.' };
 if (statusCode >= 500) return { type: 'server', message: 'Our servers are having trouble. Please try again shortly.' };
 return { type: 'unknown', message: 'Something went wrong. Please try again.' };
}
Enter fullscreen mode Exit fullscreen mode

4.2 User-Friendly Error Components

Your error component should always communicate what went wrong, ideally offer a recovery action, and never expose raw error objects or stack traces to end users.

function ErrorMessage({ type, message, onRetry }) {
 const icons = {
   network: '📡',
   auth: '🔐',
   forbidden: '🚫',
   notfound: '🔍',
   server: '⚠️',
   unknown: '',
 };
 return (
   <div className="error-container" role="alert">
     <span className="error-icon">{icons[type] || icons.unknown}</span>
     <h3 className="error-title">Something went wrong</h3>
     <p className="error-message">{message}</p>
     {onRetry && (
       <button onClick={onRetry} className="retry-button">
         Try Again
       </button>
     )}
   </div>
 );
}

Enter fullscreen mode Exit fullscreen mode

4.3 React Error Boundaries

For rendering errors (not async errors), React's Error Boundary class component catches exceptions thrown during render and prevents the entire tree from unmounting. Pair them with your async error states for comprehensive coverage.

class ErrorBoundary extends React.Component {
 constructor(props) {
   super(props);
   this.state = { hasError: false, error: null };
 }
 static getDerivedStateFromError(error) {
   return { hasError: true, error };
 }
 componentDidCatch(error, info) {
   console.error('ErrorBoundary caught:', error, info);
   // Send to error tracking service (e.g., Sentry)
 }
 render() {
   if (this.state.hasError) {
     return this.props.fallback || <DefaultErrorFallback />;
   }
   return this.props.children;
 }
}
// Usage
<ErrorBoundary fallback={<p>Something went wrong.</p>}>
 <UserDashboard />
</ErrorBoundary>
Enter fullscreen mode Exit fullscreen mode

5. Building a Custom useFetch Hook

Repeating loading/error/success logic in every component is a maintenance nightmare. A custom hook centralizes this logic, ensures consistency, and makes components dramatically cleaner.

5.1 The useFetch Hook

function useFetch(url, options = {}) {
 const [data, setData] = useState(null);
 const [loading, setLoading] = useState(true);
 const [error, setError] = useState(null);
 const abortRef = useRef(null);
 const fetchData = useCallback(async () => {
   // Abort any in-flight request
   if (abortRef.current) abortRef.current.abort();
   const controller = new AbortController();
   abortRef.current = controller;
   setLoading(true);
   setError(null);

   try {
     const res = await fetch(url, {
       ...options,
       signal: controller.signal,
     });
     if (!res.ok) {
       const errData = await res.json().catch(() => ({}));
       throw Object.assign(new Error(errData.message || res.statusText), {
         status: res.status,
       });
     }
     const result = await res.json();
     setData(result);
   } catch (err) {
     if (err.name !== 'AbortError') {
       setError({ message: err.message, status: err.status });
     }
   } finally {
     setLoading(false);
   }
 }, [url]);

 useEffect(() => {
   fetchData();
   return () => abortRef.current?.abort();
 }, [fetchData]);

 return { data, loading, error, refetch: fetchData };
}
// Usage in a component
function ProductList() {
 const { data, loading, error, refetch } = useFetch('/api/products');

 if (loading) return <ProductListSkeleton />;
 if (error) return <ErrorMessage message={error.message} onRetry={refetch} />;
 if (!data?.length) return <EmptyState />;

 return (
   <ul>
     {data.map(product => <ProductCard key={product.id} product={product} />)}
   </ul>
 );
}
Enter fullscreen mode Exit fullscreen mode

5.2 Adding Retry Logic with Exponential Backoff

Transient network errors often resolve themselves within seconds. Implementing automatic retry with exponential backoff dramatically improves resilience in unstable network environments.

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
 for (let attempt = 0; attempt <= maxRetries; attempt++) {
   try {
     const res = await fetch(url, options);
     if (!res.ok && res.status >= 500 && attempt < maxRetries) {
       // Retry on 5xx with exponential backoff: 1s, 2s, 4s
       await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
       continue;
     }
     return res;
   } catch (err) {
     if (attempt === maxRetries) throw err;
     await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
   }
 }
}
Enter fullscreen mode Exit fullscreen mode

There are only two types of API calls: those that have failed, and those that have not failed yet. Plan for both. - Unknown, React Community Proverb

6. Advanced Techniques

6.1 Using React Query for Robust Data Fetching

For production applications, React Query (now TanStack Query) provides battle-tested data fetching with built-in caching, background refetching, stale-while-revalidate patterns, and automatic retry - all without writing custom hooks from scratch.

import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
 const { data, isLoading, isError, error, refetch } = useQuery({
   queryKey: ['user', userId],
   queryFn: () => fetch(`/api/users/${userId}`).then(res => {
     if (!res.ok) throw new Error('Failed to fetch user');
     return res.json();
   }),
   retry: 3,
   retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 10000),
   staleTime: 5 * 60 * 1000, // 5 minutes
 });

 if (isLoading) return <LoadingSkeleton />;
 if (isError) return <ErrorMessage message={error.message} onRetry={refetch} />;
 return <ProfileCard user={data} />;
}
Enter fullscreen mode Exit fullscreen mode

6.2 Axios Interceptors for Global Error Handling

When using Axios, interceptors allow you to handle common error scenarios globally - such as redirecting to login on 401, showing a toast notification on 5xx errors, or refreshing tokens transparently.

import axios from 'axios';
import toast from 'react-hot-toast';
const api = axios.create({ baseURL: '/api' });
api.interceptors.response.use(
 response => response,
 error => {
   const status = error.response?.status;
   if (status === 401) {
     window.location.href = '/login';
   } else if (status >= 500) {
     toast.error('Server error. Our team has been notified.');
   } else if (!error.response) {
     toast.error('Network error. Please check your connection.');
   }
   return Promise.reject(error);
 }
);
export default api;
Enter fullscreen mode Exit fullscreen mode

6.3 Optimistic Updates

For actions like liking a post or toggling a bookmark, optimistic updates apply the change to the UI immediately before the server confirms it. If the request fails, the UI rolls back. This technique makes interfaces feel instantaneous.

instantaneous.
function LikeButton({ postId, initialLiked }) {
 const [liked, setLiked] = useState(initialLiked);
 const [likeCount, setLikeCount] = useState(0);
 async function handleLike() {
   // Optimistically update UI
   const wasLiked = liked;
   setLiked(!wasLiked);
   setLikeCount(c => wasLiked ? c - 1 : c + 1);
   try {
     await api.post(`/posts/${postId}/like`, { liked: !wasLiked });
   } catch {
     setLiked(wasLiked);
     setLikeCount(c => wasLiked ? c + 1 : c - 1);
     toast.error('Could not update like. Please try again.');
   }
 }
 return <button onClick={handleLike}>{liked ? '❤️' : '🤍'} {likeCount}</button>;
}
Enter fullscreen mode Exit fullscreen mode

6.4 Global Loading Indicator

For applications with many simultaneous API calls, a global loading bar (like the thin progress bar at the top of GitHub's pages) provides ambient feedback without disrupting the UI. Libraries like NProgress integrate cleanly with React.

import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
// In your Axios instance
api.interceptors.request.use(config => { NProgress.start(); return config; });
api.interceptors.response.use(
 response => { NProgress.done(); return response; },
 error => { NProgress.done(); return Promise.reject(error); }
);
Enter fullscreen mode Exit fullscreen mode

7. Stats & Interesting Facts

Good error messages are a form of documentation. They tell the user what went wrong, why it matters, and what to do about it. - Jakob Nielsen, Nielsen Norman Group

8. FAQ

1. Why should I model loading and error states separately rather than using a single "status" string?

Ans: Using separate boolean flags (loading, error) is simpler for most cases and avoids state machine complexity. However, a single status enum ("idle" | "loading" | "success" | "error") is a valid and arguably more explicit pattern, especially as state logic grows. The critical principle is that all three states must always be represented - never leave any one implicit.

2. What is the difference between a loading skeleton and a placeholder?

Ans: A skeleton screen mimics the actual layout and shape of the content that will appear, giving users a structural preview. A generic placeholder (e.g., a grey box or spinner) gives no structural information. Skeleton screens outperform generic placeholders in perceived performance because they orient the user before the content arrives.

3. Should I use React Query or write custom hooks for data fetching?

Ans: For simple, one-off fetch calls, a custom hook is sufficient and avoids adding a dependency. For production apps with complex caching, pagination, background syncing, or optimistic updates, React Query (TanStack Query) or SWR are strongly recommended. They solve problems that custom hooks inevitably re-implement poorly over time.

4. How should I handle validation errors returned from an API (HTTP 422)?

Ans: Parse the error response body to extract field-level validation messages and display them directly adjacent to the relevant form fields. Never show a generic error message for validation failures - users need to know exactly which field failed and why. React Hook Form and Formik both integrate well with server-side validation error patterns.

5. What should I do if a component unmounts before an API call completes?

Ans: Always use an AbortController to cancel in-flight requests in your useEffect cleanup function. Without this, the component will attempt to update state after unmounting, causing the classic React warning about updating state on an unmounted component, and potentially causing memory leaks.

6. Is it ever appropriate to silently swallow API errors without user feedback?

Ans: Only for non-critical background operations where failure has no impact on the user's task - for example, firing an analytics event or pre-fetching secondary content. For any operation the user explicitly triggered or depends on, always provide feedback. Silent failures destroy user trust.

7. How should I handle errors in React Server Components (Next.js App Router)?

Ans: In Next.js App Router, use the error.tsx file convention to define error boundaries per route segment. For server-side data fetching, use try/catch in async server components and return appropriate fallback UI. The notFound() function handles 404 cases, while the error.tsx boundary handles unexpected errors.

8. How do I test loading and error states in React components?

Ans: Use Mock Service Worker (MSW) to intercept API calls in tests without mocking fetch directly. MSW allows you to simulate slow responses (for loading state tests), 4xx/5xx error responses (for error state tests), and successful responses - giving you realistic integration test coverage of all three states.

9. Conclusion.

Handling API errors and loading states cleanly is not a nice-to-have - it is the baseline of professional React development. Every layer described in this article serves a specific purpose in a complete, user-respecting data fetching strategy:

  • Explicit state modeling ensures loading, error, and success are always accounted for - never left implicit.
  • Skeleton screens reduce perceived loading time by orienting users to the page structure before content arrives.
  • Error classification provides users with actionable, context-specific messages rather than generic failure notices.
  • Custom hooks centralize data fetching logic and eliminate repetition across components.
  • Retry logic and request cancellation add resilience and prevent memory leaks in production environments.
  • React Query and Axios interceptors provide production-grade abstractions for complex data fetching scenarios.

React provides all the primitives you need. The responsibility lies with developers to compose them thoughtfully, test all three states rigorously, and treat error handling as a first-class feature rather than an afterthought.

The best UX is invisible - a user who never sees a cryptic error message, never stares at a blank screen, and always feels in control is experiencing excellent error handling. That invisibility is the mark of a truly polished React application.

About the Author:Abodh is a PHP and Laravel Developer at AddWeb Solution, skilled in MySQL, REST APIs, JavaScript, Git, and Docker for building robust web applications.

Top comments (0)