Back to Blog
·5 min read

Why I stopped using React's useEffect for API error handling

useEffect seems like the right place for error handling, but it creates unpredictable error states and makes debugging a nightmare.

AI Dev
react
error-handling
useeffect
api

Why I stopped using React's useEffect for API error handling

I used to handle API errors inside useEffect hooks. It felt logical -- fetch data in the effect, catch errors in the same place, update error state accordingly. The pattern was straightforward: wrap the async call in a try-catch, set loading to false, and display the error message. Most tutorials showed this approach, and it worked fine for simple cases. Then I built a dashboard with multiple API calls, real-time updates, and user interactions that could trigger new requests while old ones were still pending.

The breaking point came when users started reporting inconsistent error states. Sometimes error messages would flash briefly then disappear. Other times, stale error messages would persist even after successful requests. I had error handling scattered across multiple useEffect hooks, each managing its own error state, with no clear way to coordinate between them. When I needed to implement retry logic, I realized I had no reliable way to track which request had failed or whether the user had already moved on to a different part of the interface.

That's when I learned that useEffect creates unpredictable error boundaries. Effects run independently, error states can become stale, and there's no built-in way to cancel or coordinate error handling across multiple async operations.

The useEffect error handling trap

Here's the pattern I used to write everywhere:

const [data, setData] = useState(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
 
useEffect(() => {
  const fetchData = async () => {
    setLoading(true);
    setError('');
    try {
      const response = await api.getData();
      setData(response);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };
  
  fetchData();
}, []);

This looks clean, but it creates several problems:

  • Race conditions: If the component unmounts or the effect re-runs while a request is pending, you'll try to set state on an unmounted component
  • Stale errors: Error state persists independently of the data state, leading to confusing UI states where you have both data and an error
  • No error recovery: Once an error occurs, there's no clean way for other parts of the component to retry or clear the error
  • Testing complexity: You need to mock the entire effect lifecycle to test error scenarios

What I use instead: React Query's error handling

I replaced all my useEffect error handling with React Query's built-in error management:

import { useQuery } from '@tanstack/react-query';
 
function Dashboard() {
  const { data, error, isLoading, refetch } = useQuery({
    queryKey: ['dashboard-data'],
    queryFn: () => api.getData(),
    retry: (failureCount, error) => {
      // Only retry on network errors, not 4xx responses
      if (error.status >= 400 && error.status < 500) {
        return false;
      }
      return failureCount < 3;
    },
    retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
  });
 
  if (error) {
    return (
      <ErrorBoundary 
        error={error} 
        onRetry={() => refetch()}
        onDismiss={() => queryClient.removeQueries(['dashboard-data'])}
      />
    );
  }
 
  return <DashboardContent data={data} loading={isLoading} />;
}

This approach gives me:

  • Predictable error states: Error and data states are mutually exclusive -- you never have both
  • Built-in retry logic: Configurable retry attempts with exponential backoff
  • Automatic error recovery: Errors clear automatically when the query succeeds
  • Better testing: I can test error scenarios by mocking the query function instead of component lifecycle

The key insight about error boundaries

The real breakthrough was understanding that errors should be handled at the data layer, not the UI layer. When I was using useEffect, I was mixing data fetching concerns with component lifecycle concerns. React Query separates these cleanly:

  • The query handles the data fetching and error states
  • The component handles the presentation of those states
  • Error boundaries handle unexpected errors that escape the query layer

For complex error scenarios, I create dedicated error boundary components:

interface ErrorBoundaryProps {
  error: Error;
  onRetry: () => void;
  onDismiss: () => void;
}
 
function ErrorBoundary({ error, onRetry, onDismiss }: ErrorBoundaryProps) {
  const isNetworkError = error.message.includes('fetch');
  const isServerError = error.status >= 500;
  
  return (
    <div className="error-boundary">
      <h3>Something went wrong</h3>
      <p>{error.message}</p>
      
      {isNetworkError && (
        <p>Check your internet connection and try again.</p>
      )}
      
      {isServerError && (
        <p>Our servers are having issues. We've been notified.</p>
      )}
      
      <div className="error-actions">
        <button onClick={onRetry}>Try again</button>
        <button onClick={onDismiss}>Dismiss</button>
      </div>
    </div>
  );
}

What I learned about error handling architecture

Moving away from useEffect error handling taught me several lessons:

  • Centralize error logic: Handle errors at the data layer, not scattered across components
  • Make error states explicit: Use tools that make error and loading states mutually exclusive with data states
  • Design for recovery: Every error state should provide a clear path forward for the user
  • Test error scenarios early: Error handling is not an edge case -- it's a core user experience

The biggest mindset shift was realizing that error handling is not about catching exceptions -- it's about designing user experiences for when things go wrong. React Query's approach forces you to think about error states as first-class concerns, not afterthoughts you bolt onto your data fetching logic.

My error handling is now more predictable, my components are easier to test, and most importantly, my users have a much better experience when APIs are slow or failing. The useEffect pattern might seem simpler at first, but it scales poorly and creates more problems than it solves.