React API Integration: Data Fetching Mastery
March 9, 2025 · 9 min read
React, React Query, API, Data Fetching, Interview Prep
Introduction
Every React app needs to fetch data. While fetch and useEffect work for simple cases, production apps need caching, deduplication, background refetching, and error handling. This article covers the spectrum from basic patterns to React Query mastery.
Basic Fetch Pattern
The foundation: useEffect with state management.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchUser() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
if (!cancelled) {
setUser(data);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchUser();
return () => {
cancelled = true;
};
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <Profile user={user} />;
}
Race Condition Prevention
The cancelled flag is crucial. Without it, if userId changes rapidly, old requests can overwrite newer data. This is a common source of bugs in production apps.
Custom Data Fetching Hook
Extract the pattern into a reusable hook:
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
if (!cancelled) {
setData(json);
}
} catch (err) {
if (!cancelled) {
setError(err);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchData();
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
// Usage
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
React Query: Production Data Fetching
React Query (TanStack Query) solves problems the basic pattern doesn't:
- Caching
- Background refetching
- Request deduplication
- Pagination and infinite scroll
- Mutations with cache updates
- Optimistic updates
Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: true,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
}
Basic Query
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const {
data: user,
isLoading,
isError,
error,
refetch
} = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Spinner />;
if (isError) return <Error message={error.message} onRetry={refetch} />;
return <Profile user={user} />;
}
Query Keys
Query keys identify cached data. Structure them hierarchically:
// User-related queries
['users'] // All users
['users', userId] // Specific user
['users', userId, 'posts'] // User's posts
// Filtered data
['products', { category: 'electronics', sort: 'price' }]
// Paginated data
['products', { page: 1, limit: 20 }]
Dependent Queries
When one query depends on another:
function UserPosts({ userId }) {
// First query
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// Dependent query - only runs when user is available
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user.id),
enabled: !!user, // Only fetch when user exists
});
return (
<div>
<UserHeader user={user} />
<PostList posts={posts} />
</div>
);
}
Mutations
For creating, updating, or deleting data:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePostForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newPost) => createPost(newPost),
onSuccess: () => {
// Invalidate and refetch posts
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
onError: (error) => {
toast.error(`Failed to create post: ${error.message}`);
},
});
const handleSubmit = (data) => {
mutation.mutate(data);
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Optimistic Updates
Update UI immediately, rollback on error:
function TodoItem({ todo }) {
const queryClient = useQueryClient();
const toggleMutation = useMutation({
mutationFn: (id) => toggleTodo(id),
onMutate: async (id) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old) =>
old.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
);
// Return context for rollback
return { previousTodos };
},
onError: (err, id, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context.previousTodos);
toast.error('Failed to update');
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<li onClick={() => toggleMutation.mutate(todo.id)}>
{todo.completed ? '✓' : '○'} {todo.text}
</li>
);
}
Not Everything Needs Optimistic Updates
When building Glucoplate, I learned that optimistic updates aren't always the answer. Sometimes restructuring your API to return more data upfront eliminates the need for complex rollback logic. If the client already has everything it needs to show the result, a simple loading state works fine. Profile your actual UX before adding complexity.
Pagination
function ProductList() {
const [page, setPage] = useState(1);
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts({ page, limit: 20 }),
placeholderData: (previousData) => previousData, // Keep previous data while loading
});
return (
<div>
<ProductGrid
products={data?.products}
style={{ opacity: isPlaceholderData ? 0.5 : 1 }}
/>
<Pagination
currentPage={page}
totalPages={data?.totalPages}
onPageChange={setPage}
disabled={isLoading}
/>
</div>
);
}
Infinite Scroll
import { useInfiniteQuery } from '@tanstack/react-query';
function InfiniteProductList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam = 1 }) => fetchProducts({ page: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextPage,
});
const products = data?.pages.flatMap(page => page.products) ?? [];
return (
<div>
<ProductGrid products={products} />
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Error Handling Patterns
Query-Level Error Boundary
function UserProfile({ userId }) {
const { data, isError, error, refetch } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
retry: (failureCount, error) => {
// Don't retry on 404
if (error.status === 404) return false;
return failureCount < 3;
},
});
if (isError) {
if (error.status === 404) {
return <UserNotFound />;
}
return (
<ErrorCard
message={error.message}
onRetry={refetch}
/>
);
}
return <Profile user={data} />;
}
Global Error Handling
const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError: (error) => {
if (error.status === 401) {
// Redirect to login
window.location.href = '/login';
}
},
},
mutations: {
onError: (error) => {
toast.error(`Error: ${error.message}`);
},
},
},
});
Custom Query Hooks
Encapsulate query logic in custom hooks:
// hooks/useUser.js
export function useUser(userId) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 10 * 60 * 1000, // Users don't change often
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, updates }) => updateUser(userId, updates),
onSuccess: (data, { userId }) => {
queryClient.setQueryData(['user', userId], data);
},
});
}
// Usage
function UserSettings({ userId }) {
const { data: user, isLoading } = useUser(userId);
const updateMutation = useUpdateUser();
const handleSave = (updates) => {
updateMutation.mutate({ userId, updates });
};
// ...
}
Interview Questions
Q1: Why use React Query instead of useEffect + fetch?
Answer: React Query solves problems that useEffect + fetch doesn't:
- Caching - Avoid refetching the same data
- Deduplication - Multiple components requesting same data = one request
- Background updates - Stale-while-revalidate pattern
- Retry logic - Automatic retries with exponential backoff
- Mutations - Cache invalidation and optimistic updates
- DevTools - Inspect cache state and queries
Q2: Explain staleTime vs gcTime (cacheTime).
Answer:
- staleTime - How long data is considered "fresh". Fresh data won't trigger a refetch.
- gcTime - How long inactive data stays in cache before garbage collection.
Example: staleTime: 5min, gcTime: 10min
- Data fetched, stays fresh for 5 minutes
- After 5 minutes, data is "stale" but still in cache
- Stale data shown immediately while refetch happens in background
- After 10 minutes of not being used, data is removed from cache
Q3: How do you handle optimistic updates?
Answer:
- In
onMutate: Cancel ongoing queries, snapshot current data, optimistically update cache - Return context with snapshot for potential rollback
- In
onError: Rollback using the snapshot - In
onSettled: Invalidate queries to get fresh data
Q4: What's the purpose of query keys?
Answer: Query keys uniquely identify cached data and determine when to refetch:
- Same key = cached data returned
- Key changes = new fetch triggered
- Keys are used for cache invalidation
Structure keys hierarchically for fine-grained invalidation:
invalidateQueries(['users']) // All user queries
invalidateQueries(['users', userId]) // Specific user
Common Mistakes
1. Unstable Query Keys
// ❌ Object reference changes every render
useQuery({
queryKey: ['products', { page, sort }], // OK
queryFn: () => fetch({ page, sort }), // OK
});
// But watch out for inline functions
// ❌ New function every render
queryFn: () => fetchProducts({ filters: getFilters() })
// ✅ Extract variables
const filters = getFilters();
queryFn: () => fetchProducts({ filters })
2. Not Handling Loading/Error States
// ❌ Crashes when data is undefined
const { data } = useQuery({ ... });
return <List items={data.items} />;
// ✅ Handle all states
const { data, isLoading, isError } = useQuery({ ... });
if (isLoading) return <Spinner />;
if (isError) return <Error />;
return <List items={data.items} />;
3. Forgetting to Invalidate After Mutations
// ❌ UI doesn't update after mutation
const mutation = useMutation({
mutationFn: createTodo,
// Missing: onSuccess with invalidateQueries
});
// ✅ Invalidate related queries
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Summary
- Basic pattern:
useEffect+ fetch with race condition prevention - React Query provides caching, deduplication, and background updates
- Query keys uniquely identify cached data
- Mutations for create/update/delete with cache invalidation
- Optimistic updates provide instant feedback
- Custom hooks encapsulate query logic for reuse
Next up: Forms and Validation with React Hook Form and Zod.
Part 5 of the React Developer Reference series.