Overview
Effective data fetching is crucial for building performant, user-friendly applications. This guide covers various patterns, best practices, and real-world scenarios.Modern Data Fetching Approaches
- React Query
- SWR
- Custom Hook
React Query (TanStack Query) provides powerful data synchronization with built-in caching, background updates, and request deduplication.
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading user</div>;
return <div>{data.name}</div>;
}
SWR (stale-while-revalidate) is a lightweight alternative with a focus on simplicity and performance.
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then(r => r.json());
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading } = useSWR(
`/api/users/${userId}`,
fetcher
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading user</div>;
return <div>{data.name}</div>;
}
For simple use cases, a custom hook with fetch can be sufficient.
import { useState, useEffect } from 'react';
function useUser(userId: string) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
return { data, loading, error };
}
Setting Up React Query
Configure Query Client
app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
cacheTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Create API Client
api-client.ts
class APIError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export async function apiClient<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
throw new APIError(response.status, await response.text());
}
return response.json();
}
Basic Query Patterns
Fetching Data
import { useQuery } from '@tanstack/react-query';
import { apiClient } from './api-client';
interface User {
id: string;
name: string;
email: string;
avatar?: string;
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => apiClient<User[]>('/users'),
});
}
export function useUser(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => apiClient<User>(`/users/${userId}`),
enabled: !!userId, // Only run if userId exists
});
}
Mutations (Create, Update, Delete)
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from './api-client';
interface CreateUserInput {
name: string;
email: string;
}
interface UpdateUserInput {
name?: string;
email?: string;
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (user: CreateUserInput) =>
apiClient('/users', {
method: 'POST',
body: JSON.stringify(user),
}),
onSuccess: () => {
// Invalidate and refetch users list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateUserInput }) =>
apiClient(`/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
onSuccess: (_, variables) => {
// Invalidate specific user and users list
queryClient.invalidateQueries({ queryKey: ['users', variables.id] });
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiClient(`/users/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
Use
invalidateQueries to refetch data after mutations, or use optimistic updates for instant UI feedback.Advanced Patterns
Pagination
usePaginatedUsers.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from './api-client';
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}
export function usePaginatedUsers(page: number, pageSize: number = 10) {
return useQuery({
queryKey: ['users', 'paginated', page, pageSize],
queryFn: () =>
apiClient<PaginatedResponse<User>>(
`/users?page=${page}&pageSize=${pageSize}`
),
keepPreviousData: true, // Keep old data while fetching new
});
}
PaginatedUserList.tsx
import { useState } from 'react';
import { usePaginatedUsers } from './usePaginatedUsers';
export function PaginatedUserList() {
const [page, setPage] = useState(1);
const { data, isLoading, isFetching } = usePaginatedUsers(page);
return (
<div>
{isLoading ? (
<LoadingSpinner />
) : (
<>
<ul className="space-y-2">
{data?.data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<div className="flex justify-between items-center mt-4">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border rounded"
>
Previous
</button>
<span>
Page {page} of {Math.ceil((data?.total || 0) / (data?.pageSize || 10))}
{isFetching && ' (Updating...)'}
</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={!data || page >= Math.ceil(data.total / data.pageSize)}
className="px-4 py-2 border rounded"
>
Next
</button>
</div>
</>
)}
</div>
);
}
Infinite Scroll
useInfiniteUsers.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import { apiClient } from './api-client';
export function useInfiniteUsers(pageSize: number = 20) {
return useInfiniteQuery({
queryKey: ['users', 'infinite'],
queryFn: ({ pageParam = 1 }) =>
apiClient<PaginatedResponse<User>>(
`/users?page=${pageParam}&pageSize=${pageSize}`
),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1;
const totalPages = Math.ceil(lastPage.total / lastPage.pageSize);
return nextPage <= totalPages ? nextPage : undefined;
},
});
}
InfiniteUserList.tsx
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { useInfiniteUsers } from './useInfiniteUsers';
export function InfiniteUserList() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteUsers();
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.data.map(user => (
<div key={user.id} className="p-4 border-b">
{user.name}
</div>
))}
</div>
))}
{hasNextPage && (
<div ref={ref} className="p-4 text-center">
{isFetchingNextPage ? 'Loading more...' : 'Load more'}
</div>
)}
</div>
);
}
Dependent Queries
UserWithPosts.tsx
import { useUser } from './useUsers';
import { useUserPosts } from './usePosts';
export function UserWithPosts({ userId }: { userId: string }) {
const { data: user, isLoading: userLoading } = useUser(userId);
// This query won't run until user data is loaded
const { data: posts, isLoading: postsLoading } = useUserPosts(
userId,
{ enabled: !!user }
);
if (userLoading) return <div>Loading user...</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h2>{user.name}</h2>
{postsLoading ? (
<div>Loading posts...</div>
) : (
<ul>
{posts?.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
</div>
);
}
Dependent queries are useful when you need data from one query before making another request.
Error Handling
Global Error Boundary
ErrorBoundary.tsx
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="p-4 bg-red-50 text-red-900 rounded">
<h2 className="text-lg font-bold">Something went wrong</h2>
<p>{this.state.error?.message}</p>
</div>
);
}
return this.props.children;
}
}
Query Error Handling
useUsersWithRetry.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from './api-client';
export function useUsersWithRetry() {
return useQuery({
queryKey: ['users'],
queryFn: () => apiClient('/users'),
retry: (failureCount, error) => {
// Don't retry on 404
if (error instanceof APIError && error.status === 404) {
return false;
}
// Retry up to 3 times for other errors
return failureCount < 3;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
}
Always handle errors gracefully. Display user-friendly messages and provide ways to retry failed requests.
Caching Strategies
// Fresh data - always refetch
const { data } = useQuery({
queryKey: ['critical-data'],
queryFn: fetchData,
staleTime: 0,
cacheTime: 0,
});
// Static data - never refetch
const { data } = useQuery({
queryKey: ['static-data'],
queryFn: fetchData,
staleTime: Infinity,
cacheTime: Infinity,
});
// Balanced - refetch after 5 minutes
const { data } = useQuery({
queryKey: ['user-data'],
queryFn: fetchData,
staleTime: 5 * 60 * 1000,
cacheTime: 10 * 60 * 1000,
});
Best Practices
- Use query keys consistently across your application
- Implement proper loading and error states
- Leverage caching to reduce unnecessary network requests
- Use mutations with optimistic updates for better UX
- Set up React Query Devtools for debugging
- Handle authentication tokens in your API client
- Implement retry logic for transient failures
Next Steps
- Learn about Form Handling for user input
- Explore Authentication for protected API calls
- Check out Deployment for production optimization