Not handling API failures leading to unhandled errors

Handling API Failures in React Applications

A common oversight in React development is not properly handling API failures, which can lead to uncaught errors, broken UI states, and poor user experiences. Here’s how to implement robust error handling for API calls.

The Problem (Unhandled API Errors)

// ❌ Bad - No error handling
function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch('/api/user')
      .then(response => response.json())
      .then(data => setUser(data));
  }, []);

  return <div>{user.name}</div>; // ❌ Crashes if fetch fails
}

Correct Solutions

1. Basic Error Handling

function UserProfile() {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

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

  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  return <div>{user.name}</div>;
}

2. Using Async/Await

async function fetchUser() {
  try {
    const response = await fetch('/api/user');
    if (!response.ok) throw new Error('Request failed');
    return await response.json();
  } catch (error) {
    throw new Error(`API Error: ${error.message}`);
  }
}

function UserProfile() {
  const [state, setState] = useState({ user: null, error: null, loading: true });

  useEffect(() => {
    const loadData = async () => {
      try {
        const userData = await fetchUser();
        setState({ user: userData, error: null, loading: false });
      } catch (err) {
        setState({ user: null, error: err.message, loading: false });
      }
    };
    loadData();
  }, []);

  // Render based on state
}

3. Custom Hook Solution

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

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error(response.statusText);
        const json = await response.json();
        setData(json);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [url]);

  return { data, error, loading };
}

// Usage
function UserProfile() {
  const { data: user, error, loading } = useApi('/api/user');
  // Render based on state
}

Advanced Error Handling Patterns

1. Retry Mechanism

function useApiWithRetry(url, maxRetries = 3) {
  const [state, setState] = useState({ data: null, error: null, loading: true });
  const [retryCount, setRetryCount] = useState(0);

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

    const fetchData = async () => {
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error(response.statusText);
        const json = await response.json();
        if (isMounted) setState({ data: json, error: null, loading: false });
      } catch (err) {
        if (retryCount < maxRetries) {
          setTimeout(() => setRetryCount(c => c + 1), 1000 * retryCount);
        } else if (isMounted) {
          setState({ data: null, error: err.message, loading: false });
        }
      }
    };

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

  const retry = () => {
    setState({ data: null, error: null, loading: true });
    setRetryCount(0);
  };

  return { ...state, retry };
}

2. Error Boundaries

class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

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

  render() {
    if (this.state.hasError) {
      return <FallbackUI error={this.state.error} />;
    }
    return this.props.children;
  }
}

// Usage
<ErrorBoundary>
  <UserProfile />
</ErrorBoundary>

3. Centralized Error Handling

// apiClient.js
const apiClient = {
  async request(endpoint, options = {}) {
    const response = await fetch(`/api/${endpoint}`, {
      headers: { 'Content-Type': 'application/json' },
      ...options
    });

    if (!response.ok) {
      const error = new Error(`API Error: ${response.status}`);
      error.status = response.status;
      throw error;
    }

    return response.json();
  },

  async get(endpoint) {
    return this.request(endpoint);
  },

  async post(endpoint, body) {
    return this.request(endpoint, {
      method: 'POST',
      body: JSON.stringify(body)
    });
  }
};

// Usage
try {
  const user = await apiClient.get('user');
} catch (error) {
  if (error.status === 401) {
    // Handle unauthorized
  } else {
    // Handle other errors
  }
}

Best Practices

  1. Always handle errors – Never leave API calls unprotected
  2. Provide user feedback – Show loading states and error messages
  3. Implement retries for transient failures
  4. Use proper HTTP status codes in your API
  5. Log errors to monitoring services
  6. Test error scenarios – Simulate API failures
  7. TypeScript users – Define error types:
   interface ApiError extends Error {
     status?: number;
     code?: string;
   }

Common API Failure Scenarios to Handle

  1. Network errors (offline, CORS issues)
  2. Server errors (5xx responses)
  3. Client errors (4xx responses)
  4. Invalid responses (malformed JSON)
  5. Timeout errors (long-running requests)
  6. Authentication failures (401/403 responses)

Leave a Reply

Your email address will not be published. Required fields are marked *