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.
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.