Skip to main content

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 (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>;
}

Setting Up React Query

1

Install React Query

npm install @tanstack/react-query
2

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>
  );
}
3

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

Build docs developers (and LLMs) love