React Data Fetching Strategies: From useEffect to TanStack Query & Suspense
Data fetching in React has evolved beyond simple `fetch` calls inside `useEffect`. This comprehensive guide analyzes the pitfalls of manual async state management (race conditions, strict mode double-invocations) and demonstrates how to architect robust data layers using TanStack Query, SWR, and GraphQL clients, culminating in the future-facing Suspense API.
Data Fetching
Fetch API in React
The native browser Fetch API is the simplest way to make HTTP requests in React without external dependencies. It returns Promises and works seamlessly with async/await syntax, though you need to manually handle JSON parsing and error checking since it doesn't reject on HTTP error statuses.
const [data, setData] = useState(null); useEffect(() => { fetch('https://api.example.com/users') .then(res => res.json()) .then(data => setData(data)); }, []);
Axios with React
Axios is a popular HTTP client that provides a cleaner API than Fetch, with automatic JSON transformation, request/response interceptors, request cancellation, and better error handling out of the box—it actually throws errors for non-2xx status codes.
import axios from 'axios'; useEffect(() => { axios.get('/api/users') .then(res => setData(res.data)) // Already parsed! .catch(err => console.error(err.response.status)); }, []);
useEffect for Data Fetching
useEffect is React's side-effect hook used to trigger data fetching when components mount or when dependencies change, though it requires manual handling of loading states, errors, race conditions, and cleanup—making it verbose for complex scenarios.
useEffect(() => { let cancelled = false; async function fetchData() { const res = await fetch('/api/data'); const json = await res.json(); if (!cancelled) setData(json); } fetchData(); return () => { cancelled = true; }; // Cleanup }, [userId]); // Re-fetch when userId changes
Loading States
Loading states provide visual feedback to users while data is being fetched, typically implemented as a boolean state that toggles between showing a spinner/skeleton and the actual content.
const [loading, setLoading] = useState(true); const [data, setData] = useState(null); useEffect(() => { setLoading(true); fetch('/api/data') .then(res => res.json()) .then(data => setData(data)) .finally(() => setLoading(false)); }, []); return loading ? <Spinner /> : <DataDisplay data={data} />;
┌─────────────────────────────────┐
│ loading: true → Show Spinner │
│ loading: false → Show Content │
└─────────────────────────────────┘
Error Handling
Proper error handling captures network failures and API errors, stores them in state, and displays user-friendly error messages with optional retry functionality.
const [error, setError] = useState(null); useEffect(() => { fetch('/api/data') .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then(setData) .catch(err => setError(err.message)); }, []); if (error) return <ErrorBanner message={error} onRetry={refetch} />;
Abort Controllers
AbortController allows you to cancel in-flight fetch requests when a component unmounts or when a new request supersedes an old one, preventing memory leaks and race conditions.
useEffect(() => { const controller = new AbortController(); fetch('/api/data', { signal: controller.signal }) .then(res => res.json()) .then(setData) .catch(err => { if (err.name !== 'AbortError') setError(err); }); return () => controller.abort(); // Cancel on unmount }, [query]);
Component Mount → Fetch Starts → Component Unmount
↓
controller.abort() called
↓
Request Cancelled ✓
React Query / TanStack Query
TanStack Query is a powerful data-fetching library that handles caching, background updates, stale data management, and synchronization automatically—eliminating most boilerplate code associated with server state management.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient(); function App() { return ( <QueryClientProvider client={queryClient}> <MyComponent /> </QueryClientProvider> ); }
┌──────────────────────────────────────────────────┐
│ TanStack Query Benefits │
├──────────────────────────────────────────────────┤
│ ✓ Automatic caching ✓ Background refetch │
│ ✓ Deduplication ✓ Stale-while-revalidate │
│ ✓ Pagination ✓ Infinite scroll │
│ ✓ Optimistic updates ✓ Offline support │
└──────────────────────────────────────────────────┘
useQuery Hook
useQuery is TanStack Query's primary hook for fetching and caching data, returning an object with data, loading, and error states while automatically managing cache invalidation and background refetching.
import { useQuery } from '@tanstack/react-query'; function Users() { const { data, isLoading, error } = useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/users').then(r => r.json()), staleTime: 5 * 60 * 1000, // 5 minutes }); if (isLoading) return <Spinner />; if (error) return <Error message={error.message} />; return <UserList users={data} />; }
useMutation Hook
useMutation handles data modifications (POST, PUT, DELETE) with built-in support for loading states, error handling, optimistic updates, and automatic cache invalidation after successful mutations.
import { useMutation, useQueryClient } from '@tanstack/react-query'; function AddUser() { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (newUser) => axios.post('/api/users', newUser), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); }, }); return ( <button onClick={() => mutation.mutate({ name: 'John' })} disabled={mutation.isPending} > {mutation.isPending ? 'Adding...' : 'Add User'} </button> ); }
Query Invalidation
Query invalidation marks cached data as stale, triggering a refetch—essential for keeping UI synchronized with server state after mutations or external events.
const queryClient = useQueryClient(); // Invalidate single query queryClient.invalidateQueries({ queryKey: ['users'] }); // Invalidate with prefix matching queryClient.invalidateQueries({ queryKey: ['users', userId] }); // Invalidate all queries queryClient.invalidateQueries(); // Invalidate and refetch immediately queryClient.refetchQueries({ queryKey: ['users'] });
Mutation Success
↓
invalidateQueries(['users'])
↓
Cache marked stale
↓
Background refetch triggers
↓
UI updates with fresh data
Query Caching
TanStack Query automatically caches query results using unique query keys, serving cached data instantly while optionally revalidating in the background based on configurable stale times and cache times.
const { data } = useQuery({ queryKey: ['user', userId], // Unique cache key queryFn: fetchUser, staleTime: 1000 * 60 * 5, // Fresh for 5 min gcTime: 1000 * 60 * 30, // Keep in cache 30 min (formerly cacheTime) });
┌─────────────────────────────────────────────────────────┐
│ Cache Lifecycle │
├─────────────────────────────────────────────────────────┤
│ Fetch → Fresh (staleTime) → Stale → Garbage (gcTime) │
│ ↑ ↓ │
│ └─── Refetch on focus/mount ┘ │
└─────────────────────────────────────────────────────────┘
Query Refetching
Query refetching can be triggered manually, on window focus, on reconnect, or at intervals—providing multiple strategies to keep data fresh based on application requirements.
const { data, refetch } = useQuery({ queryKey: ['dashboard'], queryFn: fetchDashboard, refetchOnWindowFocus: true, // Refetch when tab gains focus refetchOnReconnect: true, // Refetch on network reconnect refetchInterval: 30000, // Poll every 30 seconds refetchIntervalInBackground: false, }); // Manual refetch <button onClick={() => refetch()}>Refresh</button>
Infinite Queries
useInfiniteQuery handles paginated data and infinite scroll patterns, managing page parameters and accumulating results across multiple fetches automatically.
import { useInfiniteQuery } from '@tanstack/react-query'; const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam), getNextPageParam: (lastPage) => lastPage.nextCursor, }); return ( <> {data?.pages.map(page => page.items.map(post => <Post key={post.id} {...post} />) )} <button onClick={() => fetchNextPage()} disabled={!hasNextPage}> {isFetchingNextPage ? 'Loading...' : 'Load More'} </button> </> );
Optimistic Updates
Optimistic updates immediately reflect changes in the UI before server confirmation, rolling back if the mutation fails—creating a snappy user experience.
const mutation = useMutation({ mutationFn: updateTodo, onMutate: async (newTodo) => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const previous = queryClient.getQueryData(['todos']); // Optimistically update queryClient.setQueryData(['todos'], old => old.map(t => t.id === newTodo.id ? newTodo : t) ); return { previous }; // Context for rollback }, onError: (err, newTodo, context) => { queryClient.setQueryData(['todos'], context.previous); // Rollback }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); }, });
SWR
SWR (stale-while-revalidate) by Vercel is a lightweight data-fetching library with a similar philosophy to TanStack Query, featuring automatic revalidation, caching, and a minimal API—particularly popular in Next.js projects.
import useSWR from 'swr'; const fetcher = url => fetch(url).then(r => r.json()); function Profile() { const { data, error, isLoading, mutate } = useSWR('/api/user', fetcher); if (isLoading) return <Spinner />; if (error) return <Error />; return ( <div> <h1>{data.name}</h1> <button onClick={() => mutate()}>Refresh</button> </div> ); }
Apollo Client (GraphQL)
Apollo Client is the most popular GraphQL client for React, providing a normalized cache, declarative data fetching with hooks, and powerful state management for both remote and local data.
import { ApolloClient, InMemoryCache, ApolloProvider, useQuery, gql } from '@apollo/client'; const client = new ApolloClient({ uri: 'https://api.example.com/graphql', cache: new InMemoryCache(), }); const GET_USERS = gql` query GetUsers { users { id name email } } `; function Users() { const { loading, error, data } = useQuery(GET_USERS); if (loading) return <Spinner />; return data.users.map(u => <User key={u.id} {...u} />); }
urql (GraphQL)
urql is a lightweight, highly customizable GraphQL client that prioritizes simplicity and extensibility through an "exchanges" architecture—great for teams wanting more control over GraphQL operations.
import { createClient, Provider, useQuery } from 'urql'; const client = createClient({ url: 'https://api.example.com/graphql' }); const UsersQuery = ` query { users { id name } } `; function Users() { const [result] = useQuery({ query: UsersQuery }); const { data, fetching, error } = result; if (fetching) return <Spinner />; return data.users.map(u => <div key={u.id}>{u.name}</div>); }
Suspense for Data Fetching
Suspense for data fetching allows components to "suspend" while waiting for data, showing a fallback UI declaratively—currently fully supported through libraries like TanStack Query, Relay, and Next.js.
import { Suspense } from 'react'; // Component that suspends (using TanStack Query with suspense: true) function UserProfile({ userId }) { const { data } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), }); return <div>{data.name}</div>; } function App() { return ( <Suspense fallback={<Skeleton />}> <UserProfile userId={1} /> </Suspense> ); }
┌──────────────────────────────────────┐ │ Suspense Flow │ ├──────────────────────────────────────┤ │ Component renders │ │ ↓ │ │ Data not ready → throw Promise │ │ ↓ │ │ Suspense catches → show fallback │ │ ↓ │ │ Promise resolves → render component │ └──────────────────────────────────────┘