Back to Blog
·5 min read

Why I stopped using React's useEffect for data fetching

Modern data fetching patterns with React Query and SWR eliminate most useEffect complexity while providing better user experience.

AI Dev
react
data-fetching
useeffect
performance

Why I stopped using React's useEffect for data fetching

I spent years writing useEffect hooks for API calls, managing loading states, handling errors, and dealing with race conditions. Then I discovered proper data fetching libraries like React Query and SWR, and I have not looked back. The amount of boilerplate code I was writing just to fetch data properly was ridiculous.

The breaking point came when I was debugging a production issue where users were seeing stale data after navigating between pages. The problem? My hand-rolled useEffect data fetching logic was not handling cleanup properly, leading to memory leaks and state updates on unmounted components. That's when I realized I was reinventing wheels that much smarter people had already perfected.

The useEffect Data Fetching Trap

Here's what my old data fetching code looked like -- and this is relatively clean compared to what I see in many codebases:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    let cancelled = false;
    
    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch(`/api/users/${userId}`);
        const userData = await response.json();
        
        if (!cancelled) {
          setUser(userData);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err as Error);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    };
 
    fetchUser();
 
    return () => {
      cancelled = true;
    };
  }, [userId]);
 
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>No user found</div>;
 
  return <div>{user.name}</div>;
}

This is 30+ lines just to fetch a single user. And it's missing several important features:

  • Caching -- every component mount refetches the same data
  • Background updates -- no way to refresh stale data automatically
  • Optimistic updates -- mutations require even more complex state management
  • Request deduplication -- multiple components fetching the same user create duplicate requests
  • Retry logic -- failed requests just fail permanently

The Modern Approach with React Query

Here's the same functionality using React Query:

import { useQuery } from '@tanstack/react-query';
 
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
    staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
    cacheTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
  });
 
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>No user found</div>;
 
  return <div>{user.name}</div>;
}

That's 15 lines instead of 30+, and I get all those missing features for free. But the real benefits go beyond line count.

What I Actually Gained

Automatic caching: When another component needs the same user data, React Query serves it from cache instead of making another network request. The cache invalidation is intelligent -- I can mark data as stale based on time or user actions.

Background refetching: Data automatically refreshes when the user focuses the tab or reconnects to the internet. Users always see fresh data without manual refresh buttons.

Request deduplication: If ten components all need the same data simultaneously, React Query makes exactly one request and shares the result.

Built-in retry logic: Failed requests retry automatically with exponential backoff. I can configure how many retries and how long to wait.

Optimistic updates: Mutations can optimistically update the cache before the request completes, then roll back on failure. This makes the UI feel instant.

Here's how clean mutations look:

const updateUserMutation = useMutation({
  mutationFn: (userData: Partial<User>) => 
    fetch(`/api/users/${userId}`, {
      method: 'PATCH',
      body: JSON.stringify(userData),
    }),
  onMutate: async (newData) => {
    // Optimistically update the cache
    await queryClient.cancelQueries(['users', userId]);
    const previousUser = queryClient.getQueryData(['users', userId]);
    queryClient.setQueryData(['users', userId], { ...previousUser, ...newData });
    return { previousUser };
  },
  onError: (err, newData, context) => {
    // Rollback on error
    queryClient.setQueryData(['users', userId], context?.previousUser);
  },
  onSettled: () => {
    // Refetch to ensure consistency
    queryClient.invalidateQueries(['users', userId]);
  },
});

When I Still Use useEffect

I do not use useEffect for data fetching anymore, but it's still the right tool for other side effects:

  • DOM manipulation -- focusing inputs, measuring elements, integrating third-party libraries
  • Subscriptions -- WebSocket connections, event listeners, intervals
  • Cleanup effects -- canceling timers, removing event listeners, closing connections

The key insight is that data fetching is not just a side effect -- it's a complex problem domain that requires specialized tools. Using useEffect for API calls is like using a screwdriver as a hammer. It works, but there are better tools designed specifically for the job.

React Query and SWR handle the complexity so I can focus on building features instead of managing network state. My components are simpler, my users get better performance, and I sleep better knowing my data fetching logic is battle-tested by thousands of other developers.